diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/1f85dfac686d5ea2417b2b07f7e1ff01/description b/1f85dfac686d5ea2417b2b07f7e1ff01/description deleted file mode 100644 index 8118186..0000000 --- a/1f85dfac686d5ea2417b2b07f7e1ff01/description +++ /dev/null @@ -1,4 +0,0 @@ -# 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 diff --git a/75cefad80aacbf23fc7b9c24a75aa236/description b/75cefad80aacbf23fc7b9c24a75aa236/description deleted file mode 100644 index 212fa34..0000000 --- a/75cefad80aacbf23fc7b9c24a75aa236/description +++ /dev/null @@ -1,6 +0,0 @@ -# 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? diff --git a/7da3bd5b72de0a05936b094db5d24304/description b/7da3bd5b72de0a05936b094db5d24304/description deleted file mode 100644 index f23a78e..0000000 --- a/7da3bd5b72de0a05936b094db5d24304/description +++ /dev/null @@ -1 +0,0 @@ -implement `ent edit ${COMMENT}` \ No newline at end of file diff --git a/8c73c9fd5bc4f551ee5069035ae6e866/description b/8c73c9fd5bc4f551ee5069035ae6e866/description deleted file mode 100644 index 9876137..0000000 --- a/8c73c9fd5bc4f551ee5069035ae6e866/description +++ /dev/null @@ -1 +0,0 @@ -migrate the Todo list into entomologist \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4d2d2c5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "entomologist" +version = "0.1.0" +edition = "2024" + +[features] +default = [] +log = ["dep:log", "dep:simple_logger"] + +[dev-dependencies] +pretty_assertions = "1.4.1" + +[dependencies] +anyhow = "1.0.95" +chrono = "0.4.41" +clap = { version = "4.5.26", features = ["derive", "wrap_help"] } +log = { version = "0.4.27", optional = true } +rand = "0.9.1" +serde = { version = "1.0.217", features = ["derive"] } +simple_logger = { version = "5.0.0", optional = true } +tempfile = "3.20.0" +thiserror = "2.0.11" +toml = "0.8.19" diff --git a/README.md b/README.md index 2bd9d23..71a2f8e 100644 --- a/README.md +++ b/README.md @@ -1 +1,81 @@ -This branch is used by entomologist to track issues. \ No newline at end of file +Entomologist is a distributed, collaborative, offline-first issue tracker, +backed by git. + + +# Quick start + +Entomologist provides a single executable called `ent` which performs +all interaction with the issues database. `ent --help` provides terse +usage info. + +No initialization is needed, just start using `ent` inside your git repo: + +``` +$ git clone git@server:my-repo.git +$ cd my-repo +$ ent list +# no issues shown, unless my-repo contained some already +``` + +Create an issue: +``` +$ ent new +# Starts your $EDITOR. Type in the issue description, "git-commit +# style" with a title line, optionally followed by an empty line and +# free form text. +``` + +List issues with `ent list`. Optionally takes a filter argument that +controls which issues are shown, see `ent list --help` for details. +For example, to show only new and backlog issues assigned to me or +unassigned, run `ent list state=new,backlog:assignee=$(whoami),`. + +Show all details of an issue with `ent show`. + +Modify the state of an issue using `ent state`. Supported states are New, +Backlog, InProgress, Done, and WontDo. + +Assign an issue to a person using `ent assign`. The person is just +a free-form text field for now. Make it a name, or an email address, +or whatever you want. + +Add a comment on an issue with `ent comment`. + +Edit an issue or a comment with `ent edit`. + +Add or remove tags on an issue using `ent tag`. + + +# Synchronization + +Synchronize your local issue database with the server using `ent sync`. +This will: + +1. Fetch the remote issue database branch into your local repo. + +2. Show the list of local changes not yet on the remote. + +3. Show the list of remote changes not yet incorporated into the local + branch. + +4. Merge the branches. + +5. Push the result back to the remote. + +Step 4 might fail if (for example) both sides edited the same issue in +a way that git can't merge automatically. In this case, check out the +`entomologist-data` branch, merge by hand and resolve the conflicts, +and run `ent sync` again. + + +# Git storage + +Issues are stored in a normal orphan branch in a git repo, next to but +independent of whatever else is stored in the repo. The default branch +name is `entomologist-data`. + +Anyone who has a clone of the repo has the complete issue database. + +Anyone who has write-access to the repo can modify the issue database. +The issue database branch can be modified by pull request, same as any +other branch. diff --git a/b738f2842db428df1b4aad0192a7f36c/description b/b738f2842db428df1b4aad0192a7f36c/description deleted file mode 100644 index 097bfa4..0000000 --- a/b738f2842db428df1b4aad0192a7f36c/description +++ /dev/null @@ -1 +0,0 @@ -write a manpage \ No newline at end of file diff --git a/da435e5e298b28dc223f9dcfe62a914/description b/da435e5e298b28dc223f9dcfe62a914/description deleted file mode 100644 index a1ba09a..0000000 --- a/da435e5e298b28dc223f9dcfe62a914/description +++ /dev/null @@ -1 +0,0 @@ -add user control over state transitions \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ba8faf0 --- /dev/null +++ b/install.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +BINFILE="${SCRIPT_DIR}/target/release/ent" +INSTALL_DIR="/usr/local/bin" + +cargo build --release +echo "copying ent to ${INSTALL_DIR}" +sudo cp $BINFILE $INSTALL_DIR +echo "ent installed to ${INSTALL_DIR}" diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs new file mode 100644 index 0000000..e071233 --- /dev/null +++ b/src/bin/ent/main.rs @@ -0,0 +1,597 @@ +use clap::Parser; + +use entomologist::issue::State; +#[cfg(feature = "log")] +use simple_logger; + +#[derive(Debug, clap::Parser)] +#[command(version, about, long_about = None)] +struct Args { + /// Directory containing issues. + #[arg(short = 'd', long)] + issues_dir: Option, + + /// Branch containing issues. + #[arg(short = 'b', long)] + issues_branch: Option, + + /// Type of behavior/output. + #[command(subcommand)] + command: Commands, +} + +#[derive(clap::Subcommand, Debug)] +enum Commands { + /// List issues. + List { + /// 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 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 "-". Example: "tag=bug,-docs" shows issues + /// that are tagged "bug" and not tagged "docs". Defaults to + /// including all tags and excluding none. + /// + /// "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. + New { description: Option }, + + /// Edit the description of an Issue or a Comment. + Edit { uuid: String }, + + /// Show the full description of an issue. + Show { issue_id: String }, + + /// Modify the state of an issue + State { + issue_id: String, + new_state: Option, + }, + + /// Create a new comment on an issue. + Comment { + issue_id: String, + description: Option, + }, + + /// Sync entomologist data with remote. This fetches from the remote, + /// merges the remote entomologist data branch with the local one, + /// and pushes the result back to the remote. + Sync { + /// Name of the git remote to sync with. + #[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, + }, + + /// Add or remove a Tag to/from an Issue, or list the Tags on an Issue. + Tag { + issue_id: String, + #[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( + args: &Args, + issues_database_source: &entomologist::database::IssuesDatabaseSource, +) -> anyhow::Result<()> { + match &args.command { + Commands::List { filter } => { + let issues = entomologist::database::read_issues_database(issues_database_source)?; + 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>, + >::new(); + for (uuid, issue) in issues.issues.iter() { + if !filter.include_states.contains(&issue.state) { + continue; + } + if filter.include_assignees.len() > 0 { + let assignee = match &issue.assignee { + Some(assignee) => assignee, + None => "", + }; + if !filter.include_assignees.contains(assignee) { + continue; + } + } + + if filter.include_tags.len() > 0 { + if !issue.has_any_tag(&filter.include_tags) { + continue; + } + } + if filter.exclude_tags.len() > 0 { + if issue.has_any_tag(&filter.exclude_tags) { + continue; + } + } + + 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()) + .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.creation_time.cmp(&b.creation_time) + }); + 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(""), + }; + let tags = match &issue.tags.len() { + 0 => String::from(""), + _ => { + // 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. + let mut tags = String::from(" ["); + let mut separator = ""; + for tag in &issue.tags { + tags.push_str(separator); + tags.push_str(tag); + separator = ", "; + } + tags.push_str("]"); + tags + } + }; + println!( + "{} {} {}{}{}", + uuid, + comments, + issue.title(), + assignee, + tags + ); + } + println!(""); + } + } + + Commands::New { description } => { + 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"); + return Ok(()); + } + Err(e) => { + return Err(e.into()); + } + 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 mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; + if let Some(issue) = issues.get_mut_issue(uuid) { + match issue.edit_description() { + Err(entomologist::issue::IssueError::EmptyDescription) => { + println!("aborted issue edit"); + return Ok(()); + } + Err(e) => return Err(e.into()), + Ok(()) => return Ok(()), + } + } + // No issue by that ID, check all the comments. + for (_, issue) in issues.issues.iter_mut() { + for comment in issue.comments.iter_mut() { + if comment.uuid == *uuid { + match comment.edit_description() { + Err(entomologist::comment::CommentError::EmptyDescription) => { + println!("aborted comment edit"); + return Ok(()); + } + Err(e) => return Err(e.into()), + Ok(()) => return Ok(()), + } + } + } + } + return Err(anyhow::anyhow!( + "no issue or comment with uuid {} found", + uuid + )); + } + + Commands::Show { issue_id } => { + let issues = entomologist::database::read_issues_database(issues_database_source)?; + 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); + } + } + + Commands::State { + issue_id, + new_state, + } => match new_state { + Some(new_state) => { + 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) => { + let current_state = issue.state.clone(); + issue.set_state(new_state.clone())?; + println!("issue: {}", issue_id); + println!("state: {} -> {}", current_state, new_state); + } + None => { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + } + } + } + None => { + let issues = entomologist::database::read_issues_database(issues_database_source)?; + match issues.issues.get(issue_id) { + Some(issue) => { + println!("issue: {}", issue_id); + println!("state: {}", issue.state); + } + None => { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + } + } + } + }, + + Commands::Comment { + issue_id, + description, + } => { + 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)); + }; + match issue.add_comment(description) { + Err(entomologist::issue::IssueError::CommentError( + entomologist::comment::CommentError::EmptyDescription, + )) => { + println!("aborted new comment"); + return Ok(()); + } + Err(e) => { + return Err(e.into()); + } + Ok(comment) => { + println!( + "created new comment {} on issue {}", + &comment.uuid, &issue_id + ); + } + } + } + + 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, + )?; + entomologist::git::sync(&issues_database.dir, remote, branch)?; + println!("synced {:?} with {:?}", branch, remote); + } else { + return Err(anyhow::anyhow!( + "`sync` operates on a branch, don't specify `issues_dir`" + )); + } + } + + Commands::Assign { + issue_id, + 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")); + } + 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)?; + } + } + 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(()) +} + +fn main() -> anyhow::Result<()> { + #[cfg(feature = "log")] + simple_logger::SimpleLogger::new().env().init().unwrap(); + + let args: Args = Args::parse(); + // 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)) + } + (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`" + )); + } + }; + + if let entomologist::database::IssuesDatabaseSource::Branch(branch) = &issues_database_source { + if !entomologist::git::git_branch_exists(branch)? { + entomologist::git::create_orphan_branch(branch)?; + } + } + + handle_command(&args, &issues_database_source)?; + + Ok(()) +} diff --git a/src/comment.rs b/src/comment.rs new file mode 100644 index 0000000..1fa2e36 --- /dev/null +++ b/src/comment.rs @@ -0,0 +1,230 @@ +use std::io::{IsTerminal, Write}; + +#[derive(Debug, PartialEq)] +pub struct Comment { + pub uuid: String, + pub author: String, + pub creation_time: chrono::DateTime, + pub description: String, + + /// This is the directory that the comment lives in. Only used + /// internally by the entomologist library. + pub dir: std::path::PathBuf, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommentError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error(transparent)] + EnvVarError(#[from] std::env::VarError), + #[error("Failed to parse comment")] + CommentParseError, + #[error("Failed to run git")] + GitError(#[from] crate::git::GitError), + #[error("Failed to run editor")] + EditorError, + #[error("supplied description is empty")] + EmptyDescription, + #[error("stdin/stdout is not a terminal")] + StdioIsNotTerminal, +} + +impl Comment { + pub fn new_from_dir(comment_dir: &std::path::Path) -> Result { + let mut description: Option = None; + + for direntry in comment_dir.read_dir()? { + if let Ok(direntry) = direntry { + let file_name = direntry.file_name(); + if file_name == "description" { + description = Some(std::fs::read_to_string(direntry.path())?); + } else { + #[cfg(feature = "log")] + debug!( + "ignoring unknown file in comment directory: {:?}", + file_name + ); + } + } + } + let Some(description) = description else { + return Err(CommentError::CommentParseError); + }; + + let (author, creation_time) = crate::git::git_log_oldest_author_timestamp(comment_dir)?; + + let dir = std::path::PathBuf::from(comment_dir); + + Ok(Self { + uuid: String::from( + dir.file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + ), + author, + creation_time, + description, + dir: std::path::PathBuf::from(comment_dir), + }) + } + + /// Create a new Comment on the specified Issue. Commits. + pub fn new( + issue: &crate::issue::Issue, + description: &Option, + ) -> Result { + let mut dir = std::path::PathBuf::from(&issue.dir); + dir.push("comments"); + if !dir.exists() { + std::fs::create_dir(&dir)?; + } + + let rnd: u128 = rand::random(); + let uuid = format!("{:032x}", rnd); + dir.push(&uuid); + std::fs::create_dir(&dir)?; + + let mut comment = crate::comment::Comment { + uuid, + author: String::from(""), // this will be updated from git when we re-read this comment + creation_time: chrono::Local::now(), + description: String::from(""), // this will be set immediately below + dir: dir.clone(), + }; + + match description { + Some(description) => { + if description.len() == 0 { + return Err(CommentError::EmptyDescription); + } + comment.description = String::from(description); + let description_filename = comment.description_filename(); + let mut description_file = std::fs::File::create(&description_filename)?; + write!(description_file, "{}", description)?; + } + None => comment.edit_description_file()?, + }; + + crate::git::add(&dir)?; + if crate::git::worktree_is_dirty(&dir.to_string_lossy())? { + crate::git::commit( + &dir, + &format!( + "add comment {} on issue {}", + comment.uuid, + issue + .dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + ), + )?; + } + + Ok(comment) + } + + pub fn read_description(&mut self) -> Result<(), CommentError> { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + self.description = std::fs::read_to_string(description_filename)?; + Ok(()) + } + + pub fn edit_description(&mut self) -> Result<(), CommentError> { + 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() + .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() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy() + ), + )?; + self.read_description()?; + } + Ok(()) + } + + /// Opens the Comment's `description` file in an editor. Validates + /// the editor's exit code. Updates the Comment's internal + /// description from what the user saved in the file. + /// + /// Used by Issue::add_comment() when no description is supplied, + /// and (FIXME: in the future) used by `ent edit COMMENT`. + pub fn edit_description_file(&mut self) -> Result<(), CommentError> { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Err(CommentError::StdioIsNotTerminal); + } + + let description_filename = self.description_filename(); + let exists = description_filename.exists(); + + let editor = match std::env::var("EDITOR") { + Ok(editor) => editor, + Err(std::env::VarError::NotPresent) => String::from("vi"), + Err(e) => return Err(e.into()), + }; + let result = std::process::Command::new(editor) + .arg(&description_filename.as_os_str()) + .spawn()? + .wait_with_output()?; + if !result.status.success() { + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + return Err(CommentError::EditorError); + } + + if !description_filename.exists() || description_filename.metadata()?.len() == 0 { + // User saved an empty file, which means they changed their + // mind and no longer want to edit the description. + if exists { + crate::git::restore_file(&description_filename)?; + } + return Err(CommentError::EmptyDescription); + } + self.read_description()?; + Ok(()) + } +} + +// This is the private, internal API. +impl Comment { + fn description_filename(&self) -> std::path::PathBuf { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + description_filename + } +} + +#[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/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347", + ); + let comment = Comment::new_from_dir(comment_dir).unwrap(); + let expected = Comment { + uuid: String::from("9055dac36045fe36545bed7ae7b49347"), + author: String::from("Sebastian Kuzminsky "), + 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), + }; + assert_eq!(comment, expected); + } +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..8afcd8d --- /dev/null +++ b/src/database.rs @@ -0,0 +1,94 @@ +use thiserror::Error; +use crate::{git::GitError, issues::ReadIssuesError}; + +/// Errors that the DB can emit: +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + IssuesError(#[from] ReadIssuesError), + #[error(transparent)] + GitError(#[from] GitError), +} + + +/// The main function looks at the command-line arguments and determines +/// from there where to get the Issues Database to operate on. +/// +/// * If the user specified `--issues-dir` we use that. +/// +/// * If the user specified `--issues-branch` we make sure the branch +/// exists, then use that. +/// +/// * If the user specified neither, we use the default branch +/// `entomologist-data` (after ensuring that it exists). +/// +/// * If the user specified both, it's an operator error and we abort. +/// +/// The result of that code populates an IssuesDatabaseSource object, +/// that gets used later to access the database. +pub enum IssuesDatabaseSource<'a> { + Dir(&'a std::path::Path), + Branch(&'a str), +} + + + +/// The IssuesDatabase type is a "fat path". It holds a PathBuf pointing +/// at the issues database directory, and optionally a Worktree object +/// corresponding to that path. +/// +/// The worktree field itself is never read: we put its path in `dir` +/// and that's all that the calling code cares about. +/// +/// The Worktree object is included here *when* the IssuesDatabaseSource +/// is a branch. In this case a git worktree is created to hold the +/// checkout of the branch. When the IssueDatabase object is dropped, +/// the contained/owned Worktree object is dropped, which deletes the +/// worktree directory from the filesystem and prunes the worktree from +/// git's worktree list. + +pub struct IssuesDatabase { + pub dir: std::path::PathBuf, + + #[allow(dead_code)] + pub worktree: Option, +} + +pub enum IssuesDatabaseAccess { + ReadOnly, + ReadWrite, +} + +pub fn make_issues_database( + issues_database_source: &IssuesDatabaseSource, + access_type: IssuesDatabaseAccess, +) -> Result { + match issues_database_source { + IssuesDatabaseSource::Dir(dir) => Ok(IssuesDatabase { + dir: std::path::PathBuf::from(dir), + worktree: None, + }), + IssuesDatabaseSource::Branch(branch) => { + let worktree = match access_type { + IssuesDatabaseAccess::ReadOnly => { + crate::git::Worktree::new_detached(branch)? + } + IssuesDatabaseAccess::ReadWrite => crate::git::Worktree::new(branch)?, + }; + Ok(IssuesDatabase { + dir: std::path::PathBuf::from(worktree.path()), + worktree: Some(worktree), + }) + } + } +} + +pub fn read_issues_database( + issues_database_source: &IssuesDatabaseSource, +) -> Result { + let issues_database = + make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadOnly)?; + Ok(crate::issues::Issues::new_from_dir( + &issues_database.dir, + )?) +} \ No newline at end of file diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..6e70fa8 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,547 @@ +use std::io::Write; + +#[derive(Debug, thiserror::Error)] +pub enum GitError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error(transparent)] + ParseIntError(#[from] std::num::ParseIntError), + #[error("Oops, something went wrong")] + Oops, +} + +#[derive(Debug)] +/// `Worktree` is a struct that manages a temporary directory containing +/// a checkout of a specific branch. The worktree is removed and pruned +/// when the `Worktree` struct is dropped. +pub struct Worktree { + path: tempfile::TempDir, +} + +impl Drop for Worktree { + fn drop(&mut self) { + let result = std::process::Command::new("git") + .args([ + "worktree", + "remove", + "--force", + &self.path.path().to_string_lossy(), + ]) + .output(); + match result { + Err(e) => { + println!("failed to run git: {:#?}", e); + } + Ok(result) => { + if !result.status.success() { + println!("failed to remove git worktree: {:#?}", result); + } + } + } + } +} + +impl Worktree { + pub fn new(branch: &str) -> Result { + let path = tempfile::tempdir()?; + let result = std::process::Command::new("git") + .args(["worktree", "add", &path.path().to_string_lossy(), branch]) + .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); + } + Ok(Self { path }) + } + + pub fn new_detached(branch: &str) -> Result { + let path = tempfile::tempdir()?; + let result = std::process::Command::new("git") + .args([ + "worktree", + "add", + "--detach", + &path.path().to_string_lossy(), + branch, + ]) + .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); + } + Ok(Self { path }) + } + + pub fn path(&self) -> &std::path::Path { + self.path.as_ref() + } +} + +pub fn checkout_branch_in_worktree( + branch: &str, + worktree_dir: &std::path::Path, +) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["worktree", "add", &worktree_dir.to_string_lossy(), branch]) + .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); + } + Ok(()) +} + +pub fn git_worktree_prune() -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["worktree", "prune"]) + .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); + } + Ok(()) +} + +pub fn git_remove_branch(branch: &str) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["branch", "-D", branch]) + .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); + } + Ok(()) +} + +pub fn git_branch_exists(branch: &str) -> Result { + let result = std::process::Command::new("git") + .args(["show-ref", "--quiet", branch]) + .output()?; + return Ok(result.status.success()); +} + +pub fn worktree_is_dirty(dir: &str) -> Result { + // `git status --porcelain` prints a terse list of files added or + // modified (both staged and not), and new untracked files. So if + // says *anything at all* it means the worktree is dirty. + let result = std::process::Command::new("git") + .args(["status", "--porcelain", "--untracked-files=no"]) + .current_dir(dir) + .output()?; + return Ok(result.stdout.len() > 0); +} + +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() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, + ) + .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); + } + return Ok(()); +} + +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() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, + ) + .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); + } + return Ok(()); +} + +pub fn commit(dir: &std::path::Path, msg: &str) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["commit", "-m", msg]) + .current_dir(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); + } + Ok(()) +} + +pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { + let mut git_dir = std::path::PathBuf::from(file); + git_dir.pop(); + + let result = std::process::Command::new("git") + .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: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + return Err(GitError::Oops); + } + + let result = std::process::Command::new("git") + .args([ + "commit", + "-m", + &format!( + "update '{}' in issue {}", + 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: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + return Err(GitError::Oops); + } + + Ok(()) +} + +pub fn git_fetch(dir: &std::path::Path, remote: &str) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["fetch", remote]) + .current_dir(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); + } + Ok(()) +} + +pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), GitError> { + // We do all the work in a directory that's (FIXME) hopefully a + // worktree. If anything goes wrong we just fail out and ask the + // human to fix it by hand :-/ + // 1. `git fetch` + // 2. `git merge REMOTE/BRANCH` + // 3. `git push REMOTE BRANCH` + + git_fetch(dir, remote)?; + + // FIXME: Possible things to add: + // * `git log -p` shows diff + // * `git log --numstat` shows machine-readable diffstat + + // Show what we just fetched from the remote. + let result = std::process::Command::new("git") + .args([ + "log", + "--no-merges", + "--pretty=format:%an: %s", + &format!("{}/{}", remote, branch), + &format!("^{}", branch), + ]) + .current_dir(dir) + .output()?; + if !result.status.success() { + println!( + "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", + branch + ); + 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!("{}", &String::from_utf8_lossy(&result.stdout)); + println!(""); + } + + // Show what we are about to push to the remote. + let result = std::process::Command::new("git") + .args([ + "log", + "--no-merges", + "--pretty=format:%an: %s", + &format!("{}", branch), + &format!("^{}/{}", remote, branch), + ]) + .current_dir(dir) + .output()?; + if !result.status.success() { + println!( + "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", + branch + ); + 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!("{}", &String::from_utf8_lossy(&result.stdout)); + println!(""); + } + + // Merge remote branch into local. + let result = std::process::Command::new("git") + .args(["merge", &format!("{}/{}", remote, branch)]) + .current_dir(dir) + .output()?; + if !result.status.success() { + println!( + "Sync failed! Merge error! Help, a human needs to fix the mess in {:?}", + branch + ); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + return Err(GitError::Oops); + } + + // Push merged branch to remote. + let result = std::process::Command::new("git") + .args(["push", remote, branch]) + .current_dir(dir) + .output()?; + if !result.status.success() { + println!( + "Sync failed! Push error! Help, a human needs to fix the mess in {:?}", + branch + ); + println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); + println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + return Err(GitError::Oops); + } + + 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() + .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 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() + .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 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 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(); + create_orphan_branch_at_path(branch, tmp_worktree.path())?; + } + // The temp dir is now removed / cleaned up. + + let result = std::process::Command::new("git") + .args(["worktree", "prune"]) + .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); + } + + Ok(()) +} + +fn create_orphan_branch_at_path( + branch: &str, + worktree_path: &std::path::Path, +) -> Result<(), GitError> { + let worktree_dir = worktree_path.to_string_lossy(); + let result = std::process::Command::new("git") + .args(["worktree", "add", "--orphan", "-b", branch, &worktree_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 mut readme_filename = std::path::PathBuf::from(worktree_path); + readme_filename.push("README.md"); + let mut readme = std::fs::File::create(readme_filename)?; + write!( + readme, + "This branch is used by entomologist to track issues." + )?; + + let result = std::process::Command::new("git") + .args(["add", "README.md"]) + .current_dir(worktree_path) + .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 result = std::process::Command::new("git") + .args(["commit", "-m", "create entomologist issue branch"]) + .current_dir(worktree_path) + .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); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_worktree() { + let mut p = std::path::PathBuf::new(); + { + let worktree = Worktree::new("origin/main").unwrap(); + + p.push(worktree.path()); + assert!(p.exists()); + + let mut p2 = p.clone(); + p2.push("README.md"); + assert!(p2.exists()); + } + // The temporary worktree directory is removed when the Temp variable is dropped. + assert!(!p.exists()); + } + + #[test] + fn test_create_orphan_branch() { + let rnd: u128 = rand::random(); + let mut branch = std::string::String::from("entomologist-test-branch-"); + branch.push_str(&format!("{:032x}", rnd)); + create_orphan_branch(&branch).unwrap(); + git_remove_branch(&branch).unwrap(); + } + + #[test] + fn test_branch_exists_0() { + let r = git_branch_exists("main").unwrap(); + assert_eq!(r, true); + } + + #[test] + fn test_branch_exists_1() { + let rnd: u128 = rand::random(); + let mut branch = std::string::String::from("entomologist-missing-branch-"); + branch.push_str(&format!("{:032x}", rnd)); + let r = git_branch_exists(&branch).unwrap(); + assert_eq!(r, false); + } +} diff --git a/src/issue.rs b/src/issue.rs new file mode 100644 index 0000000..06f959f --- /dev/null +++ b/src/issue.rs @@ -0,0 +1,603 @@ +use core::fmt; +use std::io::{IsTerminal, Write}; +use std::str::FromStr; + +#[cfg(feature = "log")] +use log::debug; + +#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize)] +/// These are the states an issue can be in. +pub enum State { + New, + Backlog, + Blocked, + InProgress, + Done, + WontDo, +} + +pub type IssueHandle = String; + +#[derive(Debug, PartialEq)] +pub struct Issue { + pub id: String, + pub author: String, + pub creation_time: chrono::DateTime, + pub done_time: Option>, + pub tags: Vec, + 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 + /// internally by the entomologist library. + pub dir: std::path::PathBuf, +} + +#[derive(Debug, thiserror::Error)] +pub enum IssueError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error(transparent)] + 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")] + StateParseError, + #[error("Failed to run git")] + GitError(#[from] crate::git::GitError), + #[error("Failed to run editor")] + EditorError, + #[error("supplied description is empty")] + EmptyDescription, + #[error("tag {0} not found")] + 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 { + type Err = IssueError; + fn from_str(s: &str) -> Result { + let s = s.to_lowercase(); + if s == "new" { + Ok(State::New) + } else if s == "backlog" { + Ok(State::Backlog) + } else if s == "blocked" { + Ok(State::Blocked) + } else if s == "inprogress" { + Ok(State::InProgress) + } else if s == "done" { + Ok(State::Done) + } else if s == "wontdo" { + Ok(State::WontDo) + } else { + Err(IssueError::StateParseError) + } + } +} + +impl fmt::Display for State { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let fmt_str = match self { + State::New => "new", + State::Backlog => "backlog", + State::Blocked => "blocked", + State::InProgress => "inprogress", + State::Done => "done", + State::WontDo => "wontdo", + }; + write!(f, "{fmt_str}") + } +} + +// This is the public API of Issue. +impl Issue { + pub fn new_from_dir(dir: &std::path::Path) -> Result { + 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 = 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 { + let file_name = direntry.file_name(); + if file_name == "description" { + description = Some(std::fs::read_to_string(direntry.path())?); + } 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 == "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 + .lines() + .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 { + #[cfg(feature = "log")] + debug!("ignoring unknown file in issue directory: {:?}", file_name); + } + } + } + + let Some(description) = description else { + return Err(IssueError::IssueParseError); + }; + + // 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, + creation_time, + done_time, + tags, + state: state, + dependencies, + assignee, + description, + comments, + dir: std::path::PathBuf::from(dir), + }) + } + + fn read_comments( + comments: &mut Vec, + dir: &std::path::Path, + ) -> Result<(), IssueError> { + for direntry in dir.read_dir()? { + if let Ok(direntry) = direntry { + let comment = crate::comment::Comment::new_from_dir(&direntry.path())?; + comments.push(comment); + } + } + 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, + description: &Option, + ) -> Result { + let comment = crate::comment::Comment::new(self, description)?; + Ok(comment) + } + + /// Create a new Issue in an Issues database specified by a directory. + /// The new Issue will live in a new subdirectory, named by a unique + /// Issue identifier. + /// + /// If a description string is supplied, the new Issue's description + /// will be initialized from it with no user interaction. + /// + /// If no description is supplied, the user will be prompted to + /// input one into an editor. + /// + /// On success, the new Issue with its valid description is committed + /// to the Issues database. + pub fn new(dir: &std::path::Path, description: &Option) -> Result { + let mut issue_dir = std::path::PathBuf::from(dir); + let rnd: u128 = rand::random(); + let issue_id = format!("{:032x}", rnd); + issue_dir.push(&issue_id); + std::fs::create_dir(&issue_dir)?; + + let mut issue = Self { + id: String::from(&issue_id), + author: String::from(""), + creation_time: chrono::Local::now(), + done_time: None, + tags: Vec::::new(), + 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.clone(), + }; + + match description { + Some(description) => { + if description.len() == 0 { + return Err(IssueError::EmptyDescription); + } + issue.description = String::from(description); + let description_filename = issue.description_filename(); + let mut description_file = std::fs::File::create(&description_filename)?; + write!(description_file, "{}", description)?; + } + None => issue.edit_description_file()?, + }; + + issue.commit(&format!("create new issue {}", issue_id))?; + + Ok(issue) + } + + /// Interactively edit the description of an existing Issue. + pub fn edit_description(&mut self) -> Result<(), IssueError> { + self.edit_description_file()?; + let description_filename = self.description_filename(); + 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(()) + } + + /// Return the Issue title (first line of the description). + pub fn title<'a>(&'a self) -> &'a str { + match self.description.find("\n") { + Some(index) => &self.description.as_str()[..index], + None => self.description.as_str(), + } + } + + /// 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)?; + 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(()) + } + + pub fn read_state(&mut self) -> Result<(), IssueError> { + let mut state_filename = std::path::PathBuf::from(&self.dir); + state_filename.push("state"); + let state_string = std::fs::read_to_string(state_filename)?; + self.state = State::from_str(state_string.trim())?; + 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 { + Some(assignee) => assignee.clone(), + None => String::from("None"), + }; + 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)?; + 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(()) + } + + /// Add a new Tag to the Issue. Commits. + pub fn add_tag(&mut self, tag: &str) -> Result<(), IssueError> { + let tag_string = String::from(tag); + if self.tags.contains(&tag_string) { + return Ok(()); + } + self.tags.push(tag_string); + self.tags.sort(); + self.commit_tags(&format!( + "issue {} add tag {}", + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + tag + ))?; + Ok(()) + } + + /// Remove a Tag from the Issue. Commits. + pub fn remove_tag(&mut self, tag: &str) -> Result<(), IssueError> { + let tag_string = String::from(tag); + let Some(index) = self.tags.iter().position(|x| x == &tag_string) else { + return Err(IssueError::TagNotFound(tag_string)); + }; + self.tags.remove(index); + self.commit_tags(&format!( + "issue {} remove tag {}", + self.dir + .file_name() + .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? + .to_string_lossy(), + tag + ))?; + Ok(()) + } + + pub fn has_tag(&self, tag: &str) -> bool { + let tag_string = String::from(tag); + self.tags.iter().position(|x| x == &tag_string).is_some() + } + + pub fn has_any_tag(&self, tags: &std::collections::HashSet<&str>) -> bool { + for tag in tags.iter() { + if self.has_tag(tag) { + return true; + } + } + 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. +impl Issue { + fn description_filename(&self) -> std::path::PathBuf { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + description_filename + } + + /// Read the Issue's description file into the internal Issue representation. + fn read_description(&mut self) -> Result<(), IssueError> { + let description_filename = self.description_filename(); + self.description = std::fs::read_to_string(description_filename)?; + Ok(()) + } + + /// Opens the Issue's `description` file in an editor. Validates the + /// editor's exit code. Updates the Issue's internal description + /// from what the user saved in the file. + /// + /// Used by Issue::new() when no description is supplied, and also + /// used by `ent edit ISSUE`. + fn edit_description_file(&mut self) -> Result<(), IssueError> { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Err(IssueError::StdioIsNotTerminal); + } + + let description_filename = self.description_filename(); + let exists = description_filename.exists(); + let editor = match std::env::var("EDITOR") { + Ok(editor) => editor, + Err(std::env::VarError::NotPresent) => String::from("vi"), + Err(e) => return Err(e.into()), + }; + let result = std::process::Command::new(editor) + .arg(&description_filename.as_os_str()) + .spawn()? + .wait_with_output()?; + if !result.status.success() { + 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 { + // User saved an empty file, or exited without saving while + // editing a new description file. Both means they changed + // their mind and no longer want to edit the description. + if exists { + // File existed before the user emptied it, so restore + // the original. + crate::git::restore_file(&description_filename)?; + } + return Err(IssueError::EmptyDescription); + } + self.read_description()?; + Ok(()) + } + + fn commit_tags(&self, commit_message: &str) -> Result<(), IssueError> { + let mut tags_filename = self.dir.clone(); + tags_filename.push("tags"); + let mut tags_file = std::fs::File::create(&tags_filename)?; + for tag in &self.tags { + writeln!(tags_file, "{}", tag)?; + } + 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(()) + } +} + +#[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/3943fc5c173fdf41c0a22251593cd476/"); + let issue = Issue::new_from_dir(issue_dir).unwrap(); + let expected = Issue { + id: String::from("3943fc5c173fdf41c0a22251593cd476"), + author: String::from("Sebastian Kuzminsky "), + 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("TAG2"), + 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", + ), + comments: Vec::::new(), + dir: std::path::PathBuf::from(issue_dir), + }; + assert_eq!(issue, expected); + } + + #[test] + fn read_issue_1() { + 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 "), + 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, + 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 new file mode 100644 index 0000000..d3c57c0 --- /dev/null +++ b/src/issues.rs @@ -0,0 +1,284 @@ +#[cfg(feature = "log")] +use log::debug; + +// Just a placeholder for now, get rid of this if we don't need it. +#[derive(Debug, PartialEq, serde::Deserialize)] +pub struct Config {} + +#[derive(Debug, PartialEq)] +pub struct Issues { + pub issues: std::collections::HashMap, + pub config: Config, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReadIssuesError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error(transparent)] + IssueError(#[from] crate::issue::IssueError), + #[error("cannot handle filename")] + FilenameError(std::ffi::OsString), + #[error(transparent)] + TomlDeserializeError(#[from] toml::de::Error), +} + +impl Issues { + pub fn new() -> Self { + Self { + issues: std::collections::HashMap::new(), + config: Config {}, + } + } + + 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> { + self.issues.get(issue_id) + } + + pub fn get_mut_issue(&mut self, issue_id: &str) -> Option<&mut crate::issue::Issue> { + self.issues.get_mut(issue_id) + } + + fn parse_config(&mut self, config_path: &std::path::Path) -> Result<(), ReadIssuesError> { + let config_contents = std::fs::read_to_string(config_path)?; + let config: Config = toml::from_str(&config_contents)?; + self.config = config; + Ok(()) + } + + pub fn new_from_dir(dir: &std::path::Path) -> Result { + let mut issues = Self::new(); + + for direntry in dir.read_dir()? { + if let Ok(direntry) = direntry { + if direntry.metadata()?.is_dir() { + 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; + } + Ok(issue) => { + issues.add_issue(issue); + } + } + } else if direntry.file_name() == "config.toml" { + issues.parse_config(direntry.path().as_path())?; + } else { + #[cfg(feature = "log")] + debug!( + "ignoring unknown file in issues directory: {:?}", + direntry.file_name() + ); + } + } + } + return Ok(issues); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn read_issues_0000() { + let issues_dir = std::path::Path::new("test/0000/"); + let issues = Issues::new_from_dir(issues_dir).unwrap(); + + let mut expected = Issues::new(); + + let uuid = String::from("7792b063eef6d33e7da5dc1856750c14"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); + 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("3943fc5c173fdf41c0a22251593cd476"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); + expected.add_issue( + crate::issue::Issue { + id: uuid, + author: String::from("Sebastian Kuzminsky "), + 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("TAG2"), + String::from("i-am-also-a-tag"), + String::from("tag1"), + ]), + 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, + } + ); + assert_eq!(issues, expected); + } + + #[test] + fn read_issues_0001() { + let issues_dir = std::path::Path::new("test/0001/"); + let issues = Issues::new_from_dir(issues_dir).unwrap(); + + let mut expected = Issues::new(); + + let uuid = String::from("3fa5bfd93317ad25772680071d5ac325"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); + 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, + }); + + let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); + let mut comment_dir = dir.clone(); + let comment_uuid = String::from("9055dac36045fe36545bed7ae7b49347"); + comment_dir.push("comments"); + comment_dir.push(&comment_uuid); + let mut expected_comments = Vec::::new(); + expected_comments.push( + crate::comment::Comment { + uuid: comment_uuid, + author: String::from("Sebastian Kuzminsky "), + 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( + crate::issue::Issue { + id: uuid, + author: String::from("Sebastian Kuzminsky "), + 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, + 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, + }, + ); + assert_eq!(issues, expected); + } + + #[test] + fn read_issues_0002() { + let issues_dir = std::path::Path::new("test/0002/"); + let issues = Issues::new_from_dir(issues_dir).unwrap(); + + let mut expected = Issues::new(); + + let uuid = String::from("3fa5bfd93317ad25772680071d5ac325"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); + 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("dd79c8cfb8beeacd0460429944b4ecbe"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); + expected.add_issue( + crate::issue::Issue { + id: uuid, + author: String::from("sigil-03 "), + 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, + 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, + }, + ); + + let uuid = String::from("a85f81fc5f14cb5d4851dd445dc9744c"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); + expected.add_issue( + crate::issue::Issue { + id: uuid, + author: String::from("sigil-03 "), + 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("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"), + comments: Vec::::new(), + dir, + }, + ); + assert_eq!(issues, expected); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b6245b9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,117 @@ +use std::str::FromStr; + +pub mod comment; +pub mod database; +pub mod git; +pub mod issue; +pub mod issues; + +use crate::issue::State; + +#[derive(Debug, thiserror::Error)] +pub enum ParseFilterError { + #[error("Failed to parse filter")] + 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 +// i'm starting with obvious easy things. Chumsky looks appealing but +// more research is needed. +#[derive(Debug)] +pub struct Filter<'a> { + pub include_states: std::collections::HashSet, + 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() -> Filter<'a> { + Self { + include_states: std::collections::HashSet::::from([ + State::InProgress, + State::Blocked, + State::Backlog, + State::New, + ]), + 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, + } + } + + 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)?); + } + } + + "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); + } + } + } + + "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(()) + } +} diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/description b/test/0000/3943fc5c173fdf41c0a22251593cd476/description new file mode 100644 index 0000000..e380829 --- /dev/null +++ b/test/0000/3943fc5c173fdf41c0a22251593cd476/description @@ -0,0 +1,6 @@ +this is the title of my issue + +This is the description of my issue. +It is multiple lines. +* Arbitrary contents +* But let's use markdown by convention diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags new file mode 100644 index 0000000..04e82a6 --- /dev/null +++ b/test/0000/3943fc5c173fdf41c0a22251593cd476/tags @@ -0,0 +1,3 @@ +tag1 +TAG2 +i-am-also-a-tag diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c14/assignee b/test/0000/7792b063eef6d33e7da5dc1856750c14/assignee new file mode 100644 index 0000000..fae06e3 --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c14/assignee @@ -0,0 +1 @@ +beep boop diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c14/description b/test/0000/7792b063eef6d33e7da5dc1856750c14/description new file mode 100644 index 0000000..982085a --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c14/description @@ -0,0 +1 @@ +minimal \ No newline at end of file diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c14/state b/test/0000/7792b063eef6d33e7da5dc1856750c14/state new file mode 100644 index 0000000..0737713 --- /dev/null +++ b/test/0000/7792b063eef6d33e7da5dc1856750c14/state @@ -0,0 +1 @@ +inprogress diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac325/description b/test/0001/3fa5bfd93317ad25772680071d5ac325/description new file mode 100644 index 0000000..c73d593 --- /dev/null +++ b/test/0001/3fa5bfd93317ad25772680071d5ac325/description @@ -0,0 +1 @@ +oh yeah we got titles \ No newline at end of file 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/3fa5bfd93317ad25772680071d5ac325/state b/test/0001/3fa5bfd93317ad25772680071d5ac325/state new file mode 100644 index 0000000..19f86f4 --- /dev/null +++ b/test/0001/3fa5bfd93317ad25772680071d5ac325/state @@ -0,0 +1 @@ +done diff --git a/test/0001/config.toml b/test/0001/config.toml new file mode 100644 index 0000000..dfc15f2 --- /dev/null +++ b/test/0001/config.toml @@ -0,0 +1 @@ +states = [ "open", "closed" ] 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/dd79c8cfb8beeacd0460429944b4ecbe/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description new file mode 100644 index 0000000..a65ceb6 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description @@ -0,0 +1,6 @@ +issues out the wazoo + +Lots of words +that don't say much +because this is just +a test diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state new file mode 100644 index 0000000..7f19192 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state @@ -0,0 +1 @@ +wontdo diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac325/description b/test/0002/3fa5bfd93317ad25772680071d5ac325/description new file mode 100644 index 0000000..18a1926 --- /dev/null +++ b/test/0002/3fa5bfd93317ad25772680071d5ac325/description @@ -0,0 +1 @@ +oh yeah we got titles diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac325/state b/test/0002/3fa5bfd93317ad25772680071d5ac325/state new file mode 100644 index 0000000..19f86f4 --- /dev/null +++ b/test/0002/3fa5bfd93317ad25772680071d5ac325/state @@ -0,0 +1 @@ +done 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/a85f81fc5f14cb5d4851dd445dc9744c/description b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description new file mode 100644 index 0000000..42e2ce3 --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description @@ -0,0 +1,5 @@ +issue with dependencies + +a test has begun +for dependencies we seek +intertwining life \ No newline at end of file diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state new file mode 100644 index 0000000..7f19192 --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state @@ -0,0 +1 @@ +wontdo diff --git a/test/0002/config.toml b/test/0002/config.toml new file mode 100644 index 0000000..dfc15f2 --- /dev/null +++ b/test/0002/config.toml @@ -0,0 +1 @@ +states = [ "open", "closed" ] diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description new file mode 100644 index 0000000..a65ceb6 --- /dev/null +++ b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description @@ -0,0 +1,6 @@ +issues out the wazoo + +Lots of words +that don't say much +because this is just +a test diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state new file mode 100644 index 0000000..7f19192 --- /dev/null +++ b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state @@ -0,0 +1 @@ +wontdo 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}"