diff --git a/Cargo.toml b/Cargo.toml index 864691a..4d2d2c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,9 @@ edition = "2024" default = [] log = ["dep:log", "dep:simple_logger"] +[dev-dependencies] +pretty_assertions = "1.4.1" + [dependencies] anyhow = "1.0.95" chrono = "0.4.41" diff --git a/Todo.md b/Todo.md deleted file mode 100644 index 9594c71..0000000 --- a/Todo.md +++ /dev/null @@ -1,19 +0,0 @@ -# To do - -* migrate this todo list into entomologist - -* implement user control over state transitions - -* implement `ent comment ${ISSUE} [-m ${MESSAGE}]` - - each issue dir has a `comments` subdir - - each comment is identified by a sha1-style uid - - each comment is a file or directory under the `${ISSUE}/comments` - - comments are ordered by ctime? - -* implement `ent edit ${COMMENT}` - -* implement `ent attach ${ISSUE} ${FILE}` - - each issue has its own independent namespace for attached files - - issue description & comments can reference attached files via standard md links - -* write a manpage diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index b211383..e071233 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -24,22 +24,31 @@ struct Args { enum Commands { /// List issues. List { - /// Filter string, describes issues to include in the list. - /// The filter string is composed of chunks separated by ":". - /// Each chunk is of the form "name=condition". The supported - /// names and their matching conditions are: + /// Filter strings, describes issues to include in the list. + /// Each filter string is of the form "name=condition". + /// The supported names and their matching conditions are: /// /// "state": Comma-separated list of states to list. + /// Example: "state=new,backlog". Defaults to + /// "new,backlog,blocked,inprogress". /// - /// "assignee": Comma-separated list of assignees to list. - /// Defaults to all assignees if not set. + /// "assignee": Comma-separated list of assignees to include in + /// the list. The empty string includes issues with no assignee. + /// Example: "assignee=seb," lists issues assigned to "seb" and + /// issues without an assignee. Defaults to include all issues. /// - /// "tag": Comma-separated list of tags to include or exclude - /// (if prefixed with "-"). If omitted, defaults to including - /// all tags and excluding none. + /// "tag": Comma-separated list of tags to include, or exclude + /// if prefixed with "-". Example: "tag=bug,-docs" shows issues + /// that are tagged "bug" and not tagged "docs". Defaults to + /// including all tags and excluding none. /// - #[arg(default_value_t = String::from("state=New,Backlog,Blocked,InProgress"))] - filter: String, + /// "done-time": Time range of issue completion, in the form + /// "[START]..[END]". Includes issues that were marked Done + /// between START and END. START and END are both in RFC 3339 + /// format, e.g. "YYYY-MM-DDTHH:MM:SS[+-]HH:MM". If START + /// is omitted, defaults to the beginning of time. If END is + /// omitted, defaults to the end of time. + filter: Vec, }, /// Create a new issue. @@ -84,6 +93,18 @@ enum Commands { #[arg(allow_hyphen_values = true)] tag: Option, }, + + /// Get or set the `done_time` of the Issue. + DoneTime { + issue_id: String, + done_time: Option, + }, + + /// get or add a dependency to the issue + Depend { + issue_id: String, + dependency_id: Option, + }, } fn handle_command( @@ -93,7 +114,14 @@ fn handle_command( match &args.command { Commands::List { filter } => { let issues = entomologist::database::read_issues_database(issues_database_source)?; - let filter = entomologist::Filter::new_from_str(filter)?; + let filter = { + let mut f = entomologist::Filter::new(); + for filter_str in filter { + f.parse(filter_str)?; + } + f + }; + let mut uuids_by_state = std::collections::HashMap::< entomologist::issue::State, Vec<&entomologist::issue::IssueHandle>, @@ -123,6 +151,19 @@ fn handle_command( } } + if let Some(issue_done_time) = issue.done_time { + if let Some(start_done_time) = filter.start_done_time { + if start_done_time > issue_done_time { + continue; + } + } + if let Some(end_done_time) = filter.end_done_time { + if end_done_time < issue_done_time { + continue; + } + } + } + // This issue passed all the filters, include it in list. uuids_by_state .entry(issue.state.clone()) @@ -146,7 +187,7 @@ fn handle_command( 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) + a.creation_time.cmp(&b.creation_time) }); println!("{:?}:", state); for uuid in these_uuids { @@ -191,8 +232,10 @@ fn handle_command( } Commands::New { description } => { - let issues_database = - entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; match entomologist::issue::Issue::new(&issues_database.dir, description) { Err(entomologist::issue::IssueError::EmptyDescription) => { println!("no new issue created"); @@ -203,14 +246,17 @@ fn handle_command( } Ok(issue) => { println!("created new issue '{}'", issue.title()); + println!("ID: {}", issue.id); return Ok(()); } } } Commands::Edit { uuid } => { - let issues_database = - entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; if let Some(issue) = issues.get_mut_issue(uuid) { match issue.edit_description() { @@ -245,32 +291,40 @@ fn handle_command( Commands::Show { issue_id } => { let issues = entomologist::database::read_issues_database(issues_database_source)?; - 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!(""); - println!("{}", comment.description); - } - } - None => { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); + let Some(issue) = issues.get_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + println!("issue {}", issue_id); + println!("author: {}", issue.author); + if issue.tags.len() > 0 { + print!("tags: "); + let mut separator = ""; + for tag in &issue.tags { + print!("{}{}", separator, tag); + separator = ", "; } + println!(""); + } + println!("creation_time: {}", issue.creation_time); + if let Some(done_time) = &issue.done_time { + println!("done_time: {}", done_time); + } + 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!("creation_time: {}", comment.creation_time); + println!(""); + println!("{}", comment.description); } } @@ -279,8 +333,10 @@ fn handle_command( new_state, } => match new_state { Some(new_state) => { - let issues_database = - entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; match issues.issues.get_mut(issue_id) { Some(issue) => { @@ -312,8 +368,10 @@ fn handle_command( issue_id, description, } => { - let issues_database = - entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; let Some(issue) = issues.get_mut_issue(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id)); @@ -338,9 +396,13 @@ fn handle_command( } Commands::Sync { remote } => { - if let entomologist::database::IssuesDatabaseSource::Branch(branch) = issues_database_source { - let issues_database = - entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; + if let entomologist::database::IssuesDatabaseSource::Branch(branch) = + issues_database_source + { + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; entomologist::git::sync(&issues_database.dir, remote, branch)?; println!("synced {:?} with {:?}", branch, remote); } else { @@ -353,80 +415,151 @@ fn handle_command( Commands::Assign { issue_id, new_assignee, - } => { - let issues = entomologist::database::read_issues_database(issues_database_source)?; - let Some(original_issue) = issues.issues.get(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - let old_assignee: String = match &original_issue.assignee { - Some(assignee) => assignee.clone(), - None => String::from("None"), - }; - println!("issue: {}", issue_id); - match new_assignee { - Some(new_assignee) => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - let mut issues = - entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - let Some(issue) = issues.get_mut_issue(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - println!("assignee: {} -> {}", old_assignee, new_assignee); - issue.set_assignee(new_assignee)?; + } => match new_assignee { + Some(new_assignee) => { + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; + let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + let old_assignee: String = match &issue.assignee { + Some(assignee) => assignee.clone(), + None => String::from("None"), + }; + issue.set_assignee(new_assignee)?; + println!("issue: {}", issue_id); + println!("assignee: {} -> {}", old_assignee, new_assignee); + } + None => { + let issues = entomologist::database::read_issues_database(issues_database_source)?; + let Some(original_issue) = issues.issues.get(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + let old_assignee: String = match &original_issue.assignee { + Some(assignee) => assignee.clone(), + None => String::from("None"), + }; + println!("issue: {}", issue_id); + println!("assignee: {}", old_assignee); + } + }, + + Commands::Tag { issue_id, tag } => match tag { + Some(tag) => { + // Add or remove tag. + if tag.len() == 0 { + return Err(anyhow::anyhow!("invalid zero-length tag")); } - None => { - println!("assignee: {}", old_assignee); + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; + let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + if tag.chars().nth(0).unwrap() == '-' { + let tag = &tag[1..]; + issue.remove_tag(tag)?; + } else { + issue.add_tag(tag)?; } } - } - - Commands::Tag { issue_id, tag } => { - let issues = entomologist::database::read_issues_database(issues_database_source)?; - let Some(issue) = issues.issues.get(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - match tag { - Some(tag) => { - // Add or remove tag. - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - let mut issues = - entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - let Some(issue) = issues.get_mut_issue(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - if tag.len() == 0 { - return Err(anyhow::anyhow!("invalid zero-length tag")); - } - if tag.chars().nth(0).unwrap() == '-' { - let tag = &tag[1..]; - issue.remove_tag(tag)?; - } else { - issue.add_tag(tag)?; - } - } - None => { - // Just list the tags. - match &issue.tags.len() { - 0 => println!("no tags"), - _ => { - // Could use `format!(" {:?}", issue.tags)` - // here, but that results in `["tag1", "TAG2", - // "i-am-also-a-tag"]` and i don't want the - // double-quotes around each tag. - for tag in &issue.tags { - println!("{}", tag); - } + None => { + // Just list the tags. + let issues = entomologist::database::read_issues_database(issues_database_source)?; + let Some(issue) = issues.issues.get(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + match &issue.tags.len() { + 0 => println!("no tags"), + _ => { + // Could use `format!(" {:?}", issue.tags)` + // here, but that results in `["tag1", "TAG2", + // "i-am-also-a-tag"]` and i don't want the + // double-quotes around each tag. + for tag in &issue.tags { + println!("{}", tag); } } } } - } + }, + + Commands::DoneTime { + issue_id, + done_time, + } => match done_time { + Some(done_time) => { + // Add or remove tag. + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; + let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + let done_time = match chrono::DateTime::parse_from_rfc3339(done_time) { + Ok(done_time) => done_time.with_timezone(&chrono::Local), + Err(e) => { + eprintln!("failed to parse done-time from {}", done_time); + return Err(e.into()); + } + }; + issue.set_done_time(done_time)?; + } + None => { + let issues = entomologist::database::read_issues_database(issues_database_source)?; + let Some(issue) = issues.issues.get(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + match &issue.done_time { + Some(done_time) => println!("done_time: {}", done_time), + None => println!("None"), + }; + } + }, + + Commands::Depend { + issue_id, + dependency_id, + } => match dependency_id { + Some(dep_id) => { + let ent_db = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; + let mut issues = entomologist::issues::Issues::new_from_dir(&ent_db.dir)?; + if issues.issues.contains_key(dep_id) { + if let Some(issue) = issues.issues.get_mut(issue_id) { + issue.add_dependency(dep_id.clone())?; + } else { + Err(anyhow::anyhow!("issue {} not found", issue_id))?; + }; + } else { + Err(anyhow::anyhow!("dependency {} not found", dep_id))?; + }; + } + None => { + let ent_db = entomologist::database::read_issues_database(issues_database_source)?; + + let Some(issue) = ent_db.issues.get(issue_id) else { + Err(anyhow::anyhow!("issue {} not found", issue_id))? + }; + println!("DEPENDENCIES:"); + if let Some(list) = &issue.dependencies { + for dependency in list { + println!("{}", dependency); + } + } else { + println!("NONE"); + } + } + }, } Ok(()) @@ -440,13 +573,15 @@ fn main() -> anyhow::Result<()> { // println!("{:?}", args); let issues_database_source = match (&args.issues_dir, &args.issues_branch) { - (Some(dir), None) => entomologist::database::IssuesDatabaseSource::Dir(std::path::Path::new(dir)), + (Some(dir), None) => { + entomologist::database::IssuesDatabaseSource::Dir(std::path::Path::new(dir)) + } (None, Some(branch)) => entomologist::database::IssuesDatabaseSource::Branch(branch), (None, None) => entomologist::database::IssuesDatabaseSource::Branch("entomologist-data"), (Some(_), Some(_)) => { return Err(anyhow::anyhow!( "don't specify both `--issues-dir` and `--issues-branch`" - )) + )); } }; diff --git a/src/comment.rs b/src/comment.rs index c8e26c9..1fa2e36 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -4,7 +4,7 @@ use std::io::{IsTerminal, Write}; pub struct Comment { pub uuid: String, pub author: String, - pub timestamp: chrono::DateTime, + pub creation_time: chrono::DateTime, pub description: String, /// This is the directory that the comment lives in. Only used @@ -48,19 +48,23 @@ impl Comment { } } } - if description == None { + let Some(description) = description else { return Err(CommentError::CommentParseError); - } + }; + + let (author, creation_time) = crate::git::git_log_oldest_author_timestamp(comment_dir)?; - 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()), + uuid: String::from( + dir.file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + ), author, - timestamp, - description: description.unwrap(), + creation_time, + description, dir: std::path::PathBuf::from(comment_dir), }) } @@ -84,7 +88,7 @@ impl Comment { let mut comment = crate::comment::Comment { uuid, author: String::from(""), // this will be updated from git when we re-read this comment - timestamp: chrono::Local::now(), + creation_time: chrono::Local::now(), description: String::from(""), // this will be set immediately below dir: dir.clone(), }; @@ -109,7 +113,11 @@ impl Comment { &format!( "add comment {} on issue {}", comment.uuid, - issue.dir.file_name().unwrap().to_string_lossy(), + issue + .dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), ), )?; } @@ -130,10 +138,15 @@ impl Comment { crate::git::add(&description_filename)?; if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { crate::git::commit( - &description_filename.parent().unwrap(), + &description_filename + .parent() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, &format!( "edit comment {} on issue FIXME", // FIXME: name the issue that the comment is on - self.dir.file_name().unwrap().to_string_lossy() + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy() ), )?; self.read_description()?; @@ -165,8 +178,8 @@ impl Comment { .spawn()? .wait_with_output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(CommentError::EditorError); } @@ -195,19 +208,21 @@ impl Comment { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn read_comment_0() { - let comment_dir = - std::path::Path::new("test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347"); + let comment_dir = std::path::Path::new( + "test/0001/dd79c8cfb8beeacd0460429944b4ecbe/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") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:38-06:00") .unwrap() .with_timezone(&chrono::Local), - description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe\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 fe0446a..6e70fa8 100644 --- a/src/git.rs +++ b/src/git.rs @@ -48,8 +48,8 @@ impl Worktree { .args(["worktree", "add", &path.path().to_string_lossy(), branch]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(Self { path }) @@ -67,8 +67,8 @@ impl Worktree { ]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(Self { path }) @@ -87,8 +87,8 @@ pub fn checkout_branch_in_worktree( .args(["worktree", "add", &worktree_dir.to_string_lossy(), branch]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -99,8 +99,8 @@ pub fn git_worktree_prune() -> Result<(), GitError> { .args(["worktree", "prune"]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -111,8 +111,8 @@ pub fn git_remove_branch(branch: &str) -> Result<(), GitError> { .args(["branch", "-D", branch]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -139,11 +139,14 @@ pub fn worktree_is_dirty(dir: &str) -> Result { pub fn add(file: &std::path::Path) -> Result<(), GitError> { let result = std::process::Command::new("git") .args(["add", &file.to_string_lossy()]) - .current_dir(file.parent().unwrap()) + .current_dir( + file.parent() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, + ) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } return Ok(()); @@ -152,11 +155,14 @@ pub fn add(file: &std::path::Path) -> Result<(), GitError> { pub fn restore_file(file: &std::path::Path) -> Result<(), GitError> { let result = std::process::Command::new("git") .args(["restore", &file.to_string_lossy()]) - .current_dir(file.parent().unwrap()) + .current_dir( + file.parent() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, + ) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } return Ok(()); @@ -168,8 +174,8 @@ pub fn commit(dir: &std::path::Path, msg: &str) -> Result<(), GitError> { .current_dir(dir) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -180,12 +186,18 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { git_dir.pop(); let result = std::process::Command::new("git") - .args(["add", &file.file_name().unwrap().to_string_lossy()]) + .args([ + "add", + &file + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .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()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -195,15 +207,20 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { "-m", &format!( "update '{}' in issue {}", - file.file_name().unwrap().to_string_lossy(), - git_dir.file_name().unwrap().to_string_lossy() + file.file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + git_dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .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()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -216,8 +233,8 @@ pub fn git_fetch(dir: &std::path::Path, remote: &str) -> Result<(), GitError> { .current_dir(dir) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) @@ -253,13 +270,13 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } if result.stdout.len() > 0 { println!("Changes fetched from remote {}:", remote); - println!("{}", std::str::from_utf8(&result.stdout).unwrap()); + println!("{}", &String::from_utf8_lossy(&result.stdout)); println!(""); } @@ -279,13 +296,13 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } if result.stdout.len() > 0 { println!("Changes to push to remote {}:", remote); - println!("{}", std::str::from_utf8(&result.stdout).unwrap()); + println!("{}", &String::from_utf8_lossy(&result.stdout)); println!(""); } @@ -299,8 +316,8 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! Merge error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -314,8 +331,8 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! Push error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -332,13 +349,16 @@ pub fn git_log_oldest_timestamp( "log", "--pretty=format:%at", "--", - &path.file_name().unwrap().to_string_lossy(), + &path + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .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()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); @@ -358,13 +378,16 @@ pub fn git_log_oldest_author(path: &std::path::Path) -> Result "log", "--pretty=format:%an <%ae>", "--", - &path.file_name().unwrap().to_string_lossy(), + &path + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .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()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } let author_str = std::str::from_utf8(&result.stdout).unwrap(); @@ -372,6 +395,46 @@ pub fn git_log_oldest_author(path: &std::path::Path) -> Result Ok(String::from(author_last)) } +pub fn git_log_oldest_author_timestamp( + path: &std::path::Path, +) -> Result<(String, chrono::DateTime), 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 %an <%ae>", + "--", + &path + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + ]) + .current_dir(&git_dir) + .output()?; + if !result.status.success() { + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + return Err(GitError::Oops); + } + + let raw_output_str = String::from_utf8_lossy(&result.stdout); + let Some(raw_output_last) = raw_output_str.split("\n").last() else { + return Err(GitError::Oops); + }; + let Some(index) = raw_output_last.find(' ') else { + return Err(GitError::Oops); + }; + let author_str = &raw_output_last[index + 1..]; + let timestamp_str = &raw_output_last[0..index]; + let timestamp_i64 = timestamp_str.parse::()?; + let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) + .unwrap() + .with_timezone(&chrono::Local); + + Ok((String::from(author_str), timestamp)) +} + pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); @@ -383,8 +446,8 @@ pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { .args(["worktree", "prune"]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -400,8 +463,8 @@ fn create_orphan_branch_at_path( .args(["worktree", "add", "--orphan", "-b", branch, &worktree_dir]) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -418,8 +481,8 @@ fn create_orphan_branch_at_path( .current_dir(worktree_path) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -428,8 +491,8 @@ fn create_orphan_branch_at_path( .current_dir(worktree_path) .output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } @@ -439,6 +502,7 @@ fn create_orphan_branch_at_path( #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn test_worktree() { diff --git a/src/issue.rs b/src/issue.rs index 1558578..06f959f 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -20,8 +20,10 @@ pub type IssueHandle = String; #[derive(Debug, PartialEq)] pub struct Issue { + pub id: String, pub author: String, - pub timestamp: chrono::DateTime, + pub creation_time: chrono::DateTime, + pub done_time: Option>, pub tags: Vec, pub state: State, pub dependencies: Option>, @@ -42,6 +44,8 @@ pub enum IssueError { EnvVarError(#[from] std::env::VarError), #[error(transparent)] CommentError(#[from] crate::comment::CommentError), + #[error(transparent)] + ChronoParseError(#[from] chrono::format::ParseError), #[error("Failed to parse issue")] IssueParseError, #[error("Failed to parse state")] @@ -56,6 +60,14 @@ pub enum IssueError { TagNotFound(String), #[error("stdin/stdout is not a terminal")] StdioIsNotTerminal, + #[error("Failed to parse issue ID")] + IdError, + #[error("Dependency not found")] + DepNotFound, + #[error("Dependency already exists")] + DepExists, + #[error("Self-dependency not allowed")] + DepSelf, } impl FromStr for State { @@ -103,6 +115,7 @@ impl Issue { let mut comments = Vec::::new(); let mut assignee: Option = None; let mut tags = Vec::::new(); + let mut done_time: Option> = None; for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -116,15 +129,13 @@ impl Issue { 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 - .lines() - .map(|dep| IssueHandle::from(dep)) - .collect(); - if deps.len() > 0 { - dependencies = Some(deps); - } + } else if file_name == "done_time" { + let raw_done_time = chrono::DateTime::<_>::parse_from_rfc3339( + std::fs::read_to_string(direntry.path())?.trim(), + )?; + done_time = Some(raw_done_time.into()); + } else if file_name == "dependencies" && direntry.metadata()?.is_dir() { + dependencies = Self::read_dependencies(&direntry.path())?; } else if file_name == "tags" { let contents = std::fs::read_to_string(direntry.path())?; tags = contents @@ -132,6 +143,7 @@ impl Issue { .filter(|s| s.len() > 0) .map(|tag| String::from(tag.trim())) .collect(); + tags.sort(); } else if file_name == "comments" && direntry.metadata()?.is_dir() { Self::read_comments(&mut comments, &direntry.path())?; } else { @@ -141,21 +153,32 @@ impl Issue { } } - if description == None { + let Some(description) = description else { return Err(IssueError::IssueParseError); - } + }; - let author = crate::git::git_log_oldest_author(dir)?; - let timestamp = crate::git::git_log_oldest_timestamp(dir)?; + // parse the issue ID from the directory name + let id = if let Some(parsed_id) = match dir.file_name() { + Some(name) => name.to_str(), + None => Err(IssueError::IdError)?, + } { + String::from(parsed_id) + } else { + Err(IssueError::IdError)? + }; + + let (author, creation_time) = crate::git::git_log_oldest_author_timestamp(dir)?; Ok(Self { + id, author, - timestamp, + creation_time, + done_time, tags, state: state, dependencies, assignee, - description: description.unwrap(), + description, comments, dir: std::path::PathBuf::from(dir), }) @@ -171,10 +194,30 @@ impl Issue { comments.push(comment); } } - comments.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + comments.sort_by(|a, b| a.creation_time.cmp(&b.creation_time)); Ok(()) } + fn read_dependencies(dir: &std::path::Path) -> Result>, IssueError> { + let mut dependencies: Option> = None; + for direntry in dir.read_dir()? { + if let Ok(direntry) = direntry { + match &mut dependencies { + Some(deps) => { + deps.push(direntry.file_name().into_string().unwrap()); + } + None => { + dependencies = Some(vec![direntry.file_name().into_string().unwrap()]); + } + } + } + } + if let Some(deps) = &mut dependencies { + deps.sort(); + } + Ok(dependencies) + } + /// Add a new Comment to the Issue. Commits. pub fn add_comment( &mut self, @@ -204,8 +247,10 @@ impl Issue { std::fs::create_dir(&issue_dir)?; let mut issue = Self { + id: String::from(&issue_id), author: String::from(""), - timestamp: chrono::Local::now(), + creation_time: chrono::Local::now(), + done_time: None, tags: Vec::::new(), state: State::New, dependencies: None, @@ -228,8 +273,7 @@ impl Issue { None => issue.edit_description_file()?, }; - crate::git::add(&issue_dir)?; - crate::git::commit(&issue_dir, &format!("create new issue {}", issue_id))?; + issue.commit(&format!("create new issue {}", issue_id))?; Ok(issue) } @@ -238,21 +282,15 @@ impl Issue { pub fn edit_description(&mut self) -> Result<(), IssueError> { self.edit_description_file()?; let description_filename = self.description_filename(); - crate::git::add(&description_filename)?; - if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - crate::git::commit( - &description_filename.parent().unwrap(), - &format!( - "edit description of issue {}", - description_filename - .parent() - .unwrap() - .file_name() - .unwrap() - .to_string_lossy() - ), - )?; - } + self.commit(&format!( + "edit description of issue {}", + description_filename + .parent() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + ))?; Ok(()) } @@ -264,24 +302,25 @@ impl Issue { } } - /// Change the State of the Issue. + /// Change the State of the Issue. If the new state is `Done`, + /// set the Issue `done_time`. Commits. pub fn set_state(&mut self, new_state: State) -> Result<(), IssueError> { let old_state = self.state.clone(); let mut state_filename = std::path::PathBuf::from(&self.dir); state_filename.push("state"); let mut state_file = std::fs::File::create(&state_filename)?; write!(state_file, "{}", new_state)?; - crate::git::add(&state_filename)?; - if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - crate::git::commit( - &self.dir, - &format!( - "change state of issue {}, {} -> {}", - self.dir.file_name().unwrap().to_string_lossy(), - old_state, - new_state, - ), - )?; + self.commit(&format!( + "change state of issue {}, {} -> {}", + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + old_state, + new_state, + ))?; + if new_state == State::Done { + self.set_done_time(chrono::Local::now())?; } Ok(()) } @@ -294,6 +333,27 @@ impl Issue { Ok(()) } + /// Set the `done_time` of the Issue. Commits. + pub fn set_done_time( + &mut self, + done_time: chrono::DateTime, + ) -> Result<(), IssueError> { + let mut done_time_filename = std::path::PathBuf::from(&self.dir); + done_time_filename.push("done_time"); + let mut done_time_file = std::fs::File::create(&done_time_filename)?; + write!(done_time_file, "{}", done_time.to_rfc3339())?; + self.done_time = Some(done_time.clone()); + self.commit(&format!( + "set done-time of issue {} to {}", + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + done_time, + ))?; + Ok(()) + } + /// Set the Assignee of an Issue. pub fn set_assignee(&mut self, new_assignee: &str) -> Result<(), IssueError> { let old_assignee = match &self.assignee { @@ -304,18 +364,15 @@ impl Issue { assignee_filename.push("assignee"); let mut assignee_file = std::fs::File::create(&assignee_filename)?; write!(assignee_file, "{}", new_assignee)?; - crate::git::add(&assignee_filename)?; - if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - crate::git::commit( - &self.dir, - &format!( - "change assignee of issue {}, {} -> {}", - self.dir.file_name().unwrap().to_string_lossy(), - old_assignee, - new_assignee, - ), - )?; - } + self.commit(&format!( + "change assignee of issue {}, {} -> {}", + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + old_assignee, + new_assignee, + ))?; Ok(()) } @@ -329,7 +386,10 @@ impl Issue { self.tags.sort(); self.commit_tags(&format!( "issue {} add tag {}", - self.dir.file_name().unwrap().to_string_lossy(), + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), tag ))?; Ok(()) @@ -344,7 +404,10 @@ impl Issue { self.tags.remove(index); self.commit_tags(&format!( "issue {} remove tag {}", - self.dir.file_name().unwrap().to_string_lossy(), + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), tag ))?; Ok(()) @@ -363,6 +426,46 @@ impl Issue { } return false; } + + pub fn add_dependency(&mut self, dep: IssueHandle) -> Result<(), IssueError> { + if self.id == dep { + Err(IssueError::DepSelf)?; + } + match &mut self.dependencies { + Some(v) => v.push(dep.clone()), + None => self.dependencies = Some(vec![dep.clone()]), + } + let mut dir = std::path::PathBuf::from(&self.dir); + dir.push("dependencies"); + if !dir.exists() { + std::fs::create_dir(&dir)?; + } + + dir.push(dep.clone()); + + if !dir.exists() { + std::fs::File::create(&dir)?; + self.commit(&format!("add dep {} to issue {}", dep, self.id))?; + } else { + Err(IssueError::DepExists)?; + } + Ok(()) + } + + pub fn remove_dependency(&mut self, dep: IssueHandle) -> Result<(), IssueError> { + match &mut self.dependencies { + Some(v) => { + if let Some(i) = v.iter().position(|d| d == &dep) { + v.remove(i); + } else { + Err(IssueError::DepNotFound)?; + } + } + None => Err(IssueError::DepNotFound)?, + } + self.commit(&format!("remove dep {} from issue {}", dep, self.id))?; + Ok(()) + } } // This is the internal/private API of Issue. @@ -403,8 +506,8 @@ impl Issue { .spawn()? .wait_with_output()?; if !result.status.success() { - println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); - println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(IssueError::EditorError); } if !description_filename.exists() || description_filename.metadata()?.len() == 0 { @@ -429,7 +532,15 @@ impl Issue { for tag in &self.tags { writeln!(tags_file, "{}", tag)?; } - crate::git::add(&tags_filename)?; + self.commit(commit_message)?; + Ok(()) + } + + fn commit(&self, commit_message: &str) -> Result<(), IssueError> { + crate::git::add(&self.dir)?; + if !crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { + return Ok(()); + } crate::git::commit(&self.dir, commit_message)?; Ok(()) } @@ -438,25 +549,30 @@ impl Issue { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn read_issue_0() { - let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/"); + let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { + id: String::from("3943fc5c173fdf41c0a22251593cd476"), author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::from([ - String::from("tag1"), String::from("TAG2"), - String::from("i-am-also-a-tag") + String::from("i-am-also-a-tag"), + String::from("tag1"), ]), 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"), + 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), }; @@ -465,13 +581,15 @@ mod tests { #[test] fn read_issue_1() { - let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/"); + let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c14/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { + id: String::from("7792b063eef6d33e7da5dc1856750c14"), author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: State::InProgress, dependencies: None, diff --git a/src/issues.rs b/src/issues.rs index 5c314ac..d3c57c0 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -31,8 +31,8 @@ impl Issues { } } - pub fn add_issue(&mut self, uuid: String, issue: crate::issue::Issue) { - self.issues.insert(uuid, issue); + pub fn add_issue(&mut self, issue: crate::issue::Issue) { + self.issues.insert(issue.id.clone(), issue); } pub fn get_issue(&self, issue_id: &str) -> Option<&crate::issue::Issue> { @@ -56,14 +56,19 @@ impl Issues { for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { if direntry.metadata()?.is_dir() { - let uuid = match direntry.file_name().into_string() { - Ok(uuid) => uuid, - Err(orig_string) => { - return Err(ReadIssuesError::FilenameError(orig_string)) + match crate::issue::Issue::new_from_dir(direntry.path().as_path()) { + Err(e) => { + eprintln!( + "failed to parse issue {}, skipping", + direntry.file_name().to_string_lossy() + ); + eprintln!("ignoring error: {:?}", e); + continue; } - }; - let issue = crate::issue::Issue::new_from_dir(direntry.path().as_path())?; - issues.add_issue(uuid, issue); + Ok(issue) => { + issues.add_issue(issue); + } + } } else if direntry.file_name() == "config.toml" { issues.parse_config(direntry.path().as_path())?; } else { @@ -82,6 +87,7 @@ impl Issues { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn read_issues_0000() { @@ -90,40 +96,40 @@ mod tests { let mut expected = Issues::new(); - let uuid = String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"); + let uuid = String::from("7792b063eef6d33e7da5dc1856750c14"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); - expected.add_issue( - uuid, - crate::issue::Issue { - author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") - .unwrap() - .with_timezone(&chrono::Local), - tags: Vec::::new(), - state: crate::issue::State::InProgress, - dependencies: None, - assignee: Some(String::from("beep boop")), - description: String::from("minimal"), - comments: Vec::::new(), - dir, - }, - ); + expected.add_issue(crate::issue::Issue { + id: uuid, + author: String::from("Sebastian Kuzminsky "), + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00") + .unwrap() + .with_timezone(&chrono::Local), + done_time: None, + tags: Vec::::new(), + state: crate::issue::State::InProgress, + dependencies: None, + assignee: Some(String::from("beep boop")), + description: String::from("minimal"), + comments: Vec::::new(), + dir, + }); - let uuid = String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"); + let uuid = String::from("3943fc5c173fdf41c0a22251593cd476"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); expected.add_issue( - uuid, crate::issue::Issue { + id: uuid, author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::from([ - String::from("tag1"), String::from("TAG2"), - String::from("i-am-also-a-tag") + String::from("i-am-also-a-tag"), + String::from("tag1"), ]), state: crate::issue::State::New, dependencies: None, @@ -143,27 +149,30 @@ mod tests { let mut expected = Issues::new(); - let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); + let uuid = String::from("3fa5bfd93317ad25772680071d5ac325"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); - expected.add_issue( - uuid, - crate::issue::Issue { - author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + expected.add_issue(crate::issue::Issue { + id: uuid, + author: String::from("Sebastian Kuzminsky "), + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:46-06:00") + .unwrap() + .with_timezone(&chrono::Local), + done_time: Some( + chrono::DateTime::parse_from_rfc3339("2025-07-15T15:15:15-06:00") .unwrap() .with_timezone(&chrono::Local), - tags: Vec::::new(), - state: crate::issue::State::Done, - dependencies: None, - assignee: None, - description: String::from("oh yeah we got titles"), - comments: Vec::::new(), - dir, - }, - ); + ), + tags: Vec::::new(), + state: crate::issue::State::Done, + dependencies: None, + assignee: None, + description: String::from("oh yeah we got titles"), + comments: Vec::::new(), + dir, + }); - let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); + let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); let mut comment_dir = dir.clone(); @@ -175,18 +184,19 @@ mod tests { 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"), + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:38-06:00").unwrap().with_timezone(&chrono::Local), + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe\n\nIt has multiple lines\n"), dir: std::path::PathBuf::from(comment_dir), } ); expected.add_issue( - uuid, crate::issue::Issue { + id: uuid, author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:24-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, @@ -206,36 +216,36 @@ mod tests { let mut expected = Issues::new(); - let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); + let uuid = String::from("3fa5bfd93317ad25772680071d5ac325"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); - expected.add_issue( - uuid, - crate::issue::Issue { - author: String::from("sigil-03 "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") - .unwrap() - .with_timezone(&chrono::Local), - tags: Vec::::new(), - state: crate::issue::State::Done, - dependencies: None, - assignee: None, - description: String::from("oh yeah we got titles\n"), - comments: Vec::::new(), - dir, - }, - ); + expected.add_issue(crate::issue::Issue { + id: uuid, + author: String::from("sigil-03 "), + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:38:40-06:00") + .unwrap() + .with_timezone(&chrono::Local), + done_time: None, + tags: Vec::::new(), + state: crate::issue::State::Done, + dependencies: None, + assignee: None, + description: String::from("oh yeah we got titles\n"), + comments: Vec::::new(), + dir, + }); - let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); + let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); expected.add_issue( - uuid, crate::issue::Issue { + id: uuid, author: String::from("sigil-03 "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:39:20-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, @@ -246,21 +256,22 @@ mod tests { }, ); - let uuid = String::from("a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7"); + let uuid = String::from("a85f81fc5f14cb5d4851dd445dc9744c"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); expected.add_issue( - uuid, crate::issue::Issue { + id: uuid, author: String::from("sigil-03 "), - timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:39:02-06:00") .unwrap() .with_timezone(&chrono::Local), + done_time: None, tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: Some(vec![ - crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), - crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), + crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac325"), + crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe"), ]), assignee: None, description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), diff --git a/src/lib.rs b/src/lib.rs index 17104ea..b6245b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,12 @@ use std::str::FromStr; pub mod comment; +pub mod database; pub mod git; pub mod issue; pub mod issues; -pub mod database; + +use crate::issue::State; #[derive(Debug, thiserror::Error)] pub enum ParseFilterError { @@ -12,6 +14,8 @@ pub enum ParseFilterError { ParseError, #[error(transparent)] IssueParseError(#[from] crate::issue::IssueError), + #[error(transparent)] + ChronoParseError(#[from] chrono::format::ParseError), } // FIXME: It's easy to imagine a full dsl for filtering issues, for now @@ -23,12 +27,13 @@ pub struct Filter<'a> { pub include_assignees: std::collections::HashSet<&'a str>, pub include_tags: std::collections::HashSet<&'a str>, pub exclude_tags: std::collections::HashSet<&'a str>, + pub start_done_time: Option>, + pub end_done_time: Option>, } impl<'a> Filter<'a> { - pub fn new_from_str(filter_str: &'a str) -> Result, ParseFilterError> { - use crate::issue::State; - let mut f = Filter { + pub fn new() -> Filter<'a> { + Self { include_states: std::collections::HashSet::::from([ State::InProgress, State::Blocked, @@ -38,51 +43,75 @@ impl<'a> Filter<'a> { include_assignees: std::collections::HashSet::<&'a str>::new(), include_tags: std::collections::HashSet::<&'a str>::new(), exclude_tags: std::collections::HashSet::<&'a str>::new(), - }; + start_done_time: None, + end_done_time: None, + } + } - for filter_chunk_str in filter_str.split(":") { - let tokens: Vec<&str> = filter_chunk_str.split("=").collect(); - if tokens.len() != 2 { - return Err(ParseFilterError::ParseError); + pub fn parse(&mut self, filter_str: &'a str) -> Result<(), ParseFilterError> { + let tokens: Vec<&str> = filter_str.split("=").collect(); + if tokens.len() != 2 { + return Err(ParseFilterError::ParseError); + } + + match tokens[0] { + "state" => { + self.include_states.clear(); + for s in tokens[1].split(",") { + self.include_states + .insert(crate::issue::State::from_str(s)?); + } } - match tokens[0] { - "state" => { - f.include_states.clear(); - for s in tokens[1].split(",") { - f.include_states.insert(crate::issue::State::from_str(s)?); + "assignee" => { + self.include_assignees.clear(); + for s in tokens[1].split(",") { + self.include_assignees.insert(s); + } + } + + "tag" => { + self.include_tags.clear(); + self.exclude_tags.clear(); + for s in tokens[1].split(",") { + if s.len() == 0 { + return Err(ParseFilterError::ParseError); + } + if s.chars().nth(0).unwrap() == '-' { + self.exclude_tags.insert(&s[1..]); + } else { + self.include_tags.insert(s); } } + } - "assignee" => { - f.include_assignees.clear(); - for s in tokens[1].split(",") { - f.include_assignees.insert(s); - } - } - - "tag" => { - f.include_tags.clear(); - f.exclude_tags.clear(); - for s in tokens[1].split(",") { - if s.len() == 0 { - return Err(ParseFilterError::ParseError); - } - if s.chars().nth(0).unwrap() == '-' { - f.exclude_tags.insert(&s[1..]); - } else { - f.include_tags.insert(s); - } - } - } - - _ => { - println!("unknown filter chunk '{}'", filter_chunk_str); + "done-time" => { + self.start_done_time = None; + self.end_done_time = None; + let times: Vec<&str> = tokens[1].split("..").collect(); + if times.len() > 2 { return Err(ParseFilterError::ParseError); } + if times[0].len() != 0 { + self.start_done_time = Some( + chrono::DateTime::parse_from_rfc3339(times[0])? + .with_timezone(&chrono::Local), + ); + } + if times[1].len() != 0 { + self.end_done_time = Some( + chrono::DateTime::parse_from_rfc3339(times[1])? + .with_timezone(&chrono::Local), + ); + } + } + + _ => { + println!("unknown filter string '{}'", filter_str); + return Err(ParseFilterError::ParseError); } } - Ok(f) + Ok(()) } } diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description b/test/0000/3943fc5c173fdf41c0a22251593cd476/description similarity index 100% rename from test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description rename to test/0000/3943fc5c173fdf41c0a22251593cd476/description diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags similarity index 100% rename from test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags rename to test/0000/3943fc5c173fdf41c0a22251593cd476/tags diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee b/test/0000/7792b063eef6d33e7da5dc1856750c14/assignee similarity index 100% rename from test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee rename to test/0000/7792b063eef6d33e7da5dc1856750c14/assignee diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description b/test/0000/7792b063eef6d33e7da5dc1856750c14/description similarity index 100% rename from test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description rename to test/0000/7792b063eef6d33e7da5dc1856750c14/description diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state b/test/0000/7792b063eef6d33e7da5dc1856750c14/state similarity index 100% rename from test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state rename to test/0000/7792b063eef6d33e7da5dc1856750c14/state diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description b/test/0001/3fa5bfd93317ad25772680071d5ac325/description similarity index 100% rename from test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description rename to test/0001/3fa5bfd93317ad25772680071d5ac325/description diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time b/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time new file mode 100644 index 0000000..d455c4d --- /dev/null +++ b/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time @@ -0,0 +1 @@ +2025-07-15T15:15:15-06:00 diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state b/test/0001/3fa5bfd93317ad25772680071d5ac325/state similarity index 100% rename from test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state rename to test/0001/3fa5bfd93317ad25772680071d5ac325/state diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description new file mode 100644 index 0000000..daa3d62 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description @@ -0,0 +1,3 @@ +This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe + +It has multiple lines diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description similarity index 100% rename from test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description rename to test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state similarity index 100% rename from test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state rename to test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description deleted file mode 100644 index f9de678..0000000 --- a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description +++ /dev/null @@ -1,3 +0,0 @@ -This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561 - -It has multiple lines diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/description b/test/0002/3fa5bfd93317ad25772680071d5ac325/description similarity index 100% rename from test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/description rename to test/0002/3fa5bfd93317ad25772680071d5ac325/description diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state b/test/0002/3fa5bfd93317ad25772680071d5ac325/state similarity index 100% rename from test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state rename to test/0002/3fa5bfd93317ad25772680071d5ac325/state diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/3fa5bfd93317ad25772680071d5ac325 b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/3fa5bfd93317ad25772680071d5ac325 new file mode 100644 index 0000000..e69de29 diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/dd79c8cfb8beeacd0460429944b4ecbe b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/dd79c8cfb8beeacd0460429944b4ecbe new file mode 100644 index 0000000..e69de29 diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description similarity index 100% rename from test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description rename to test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state similarity index 100% rename from test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state rename to test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies deleted file mode 100644 index 71e4ee3..0000000 --- a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies +++ /dev/null @@ -1,2 +0,0 @@ -3fa5bfd93317ad25772680071d5ac3259cd2384f -dd79c8cfb8beeacd0460429944b4ecbe95a31561 \ No newline at end of file diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description similarity index 100% rename from test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description rename to test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state similarity index 100% rename from test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state rename to test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..dd3cb2f --- /dev/null +++ b/tools/README.md @@ -0,0 +1,4 @@ +This directory contains small helper scripts and tools that are peripheral +or tangent to the main entomologist tool. + +We make no guarantees about functionality or correctness. diff --git a/tools/done-last-week b/tools/done-last-week new file mode 100755 index 0000000..da9ec94 --- /dev/null +++ b/tools/done-last-week @@ -0,0 +1,11 @@ +#!/bin/bash + +START=$(date --iso-8601=seconds --date='last monday - 1 week') +END=$(date --iso-8601=seconds --date='last monday') + +#echo START=${START} +#echo END=${END} + +ent list \ + state=done \ + done-time="${START}..${END}" diff --git a/tools/set-done-time b/tools/set-done-time new file mode 100755 index 0000000..6a29fd9 --- /dev/null +++ b/tools/set-done-time @@ -0,0 +1,19 @@ +#!/bin/bash +# +# This script finds all issues with state=Done which do not have a +# `done_time`. +# +# It sets each issue's `done_time` to the most recent time that the +# `state` was updated from the git log. +# + +set -e + +for ISSUE_ID in $(ent list state=done done-time=9999-01-01T00:00:00-06:00.. | grep ' ' | cut -f 1 -d ' '); do + echo ${ISSUE_ID} + UTIME=$(PAGER='' git log -n1 --pretty=format:%at%n entomologist-data -- ${ISSUE_ID}/state) + echo ${UTIME} + DATETIME=$(date --rfc-3339=seconds --date="@${UTIME}") + echo ${DATETIME} + ent done-time ${ISSUE_ID} "${DATETIME}" +done diff --git a/tools/time-ent b/tools/time-ent new file mode 100755 index 0000000..366a02c --- /dev/null +++ b/tools/time-ent @@ -0,0 +1,43 @@ +#!/bin/bash +# +# * Create a temporary ent issue database branch based on a specific +# commit in `entomologist-data`. +# +# * Perform some ent operations on this temporary branch and measure +# the runtime. +# +# * Clean up by deleteting the temporary branch. + +set -e +#set -x + + +# This is a commit in the `entomologist-data` branch that we're somewhat +# arbitrarily using here to time different `ent` operations. +TEST_COMMIT=a33f1165d77571d770f1a1021afe4c07360247f0 + +# This is the branch that we create from the above commit and test our +# `ent` operations on. We'll delete this branch when we're done with +# the tests. +TEST_BRANCH=$(mktemp --dry-run entomologist-data-XXXXXXXX) + + +function time_ent() { + echo timing: ent "$@" + time -p ent -b "${TEST_BRANCH}" "$@" + echo +} + + +git branch "${TEST_BRANCH}" "${TEST_COMMIT}" + +time_ent tag 7e2a3a59fb6b77403ff1035255367607 +time_ent tag 7e2a3a59fb6b77403ff1035255367607 new-tag + +time_ent assign 7e2a3a59fb6b77403ff1035255367607 +time_ent assign 7e2a3a59fb6b77403ff1035255367607 new-user + +time_ent done-time 7e2a3a59fb6b77403ff1035255367607 +time_ent done-time 7e2a3a59fb6b77403ff1035255367607 2025-04-01T01:23:45-06:00 + +git branch -D "${TEST_BRANCH}"