diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 2d0f56a..2a8f1f4 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<()> { @@ -66,11 +72,52 @@ 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(); + 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!(""); + } } Commands::New { @@ -106,15 +153,21 @@ 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 comment in &issue.comments { println!(""); println!("comment: {}", comment.uuid); + println!("author: {}", comment.author); println!("timestamp: {}", comment.timestamp); println!("{}", comment.description); } @@ -190,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 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..68cb8f9 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -20,9 +20,12 @@ 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 assignee: Option, + pub description: String, pub comments: Vec, /// This is the directory that the issue lives in. Only used @@ -88,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 { @@ -97,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 @@ -119,10 +127,16 @@ 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), }) @@ -156,6 +170,7 @@ impl Issue { Ok(crate::comment::Comment { uuid, + author: String::from("Sebastian Kuzminsky "), timestamp: chrono::Local::now(), description: String::from(""), // FIXME dir, @@ -168,9 +183,12 @@ 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, + assignee: None, + description: String::from(""), // FIXME: kind of bogus to use the empty string as None comments: Vec::::new(), dir: issue_dir, }) @@ -233,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)] @@ -244,9 +271,14 @@ 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, + 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), }; @@ -258,9 +290,14 @@ 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, + 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 06af867..16dab55 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -96,9 +96,14 @@ 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, + assignee: Some(String::from("beep boop")), + description: String::from("minimal"), comments: Vec::::new(), dir, }, @@ -110,9 +115,14 @@ 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, + 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,9 +143,14 @@ 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, + assignee: None, + description: String::from("oh yeah we got titles"), comments: Vec::::new(), dir, }, @@ -152,17 +167,23 @@ 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), } ); 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,9 +204,14 @@ 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, + assignee: None, + description: String::from("oh yeah we got titles\n"), comments: Vec::::new(), dir, }, @@ -197,9 +223,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("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, + 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,12 +242,17 @@ 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"), ]), + 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