Compare commits

...
Sign in to create a new pull request.

48 commits
03/tui ... main

Author SHA1 Message Date
seb
a37b4cc72b Merge pull request 'fix test dirs' (#35) from fix-test-dirs into main
Reviewed-on: #35
2025-07-26 10:44:34 -06:00
fad23ba233 test dir cleanup: rename test/0002/dd79c8cfb8beeacd0460429944b4ecbe
Renaming everything also means they have new creation-times, since we're
now git logging a different file/dir.
2025-07-24 10:20:03 -06:00
b3903a9ed2 test dir cleanup: rename test/0002/a85f81fc5f14cb5d4851dd445dc9744c
Renaming everything also means they have new creation-times, since we're
now git logging a different file/dir.
2025-07-24 10:19:58 -06:00
b3f5aaeb76 test dir cleanup: rename test/0002/3fa5bfd93317ad25772680071d5ac325
Renaming everything also means they have new creation-times, since we're
now git logging a different file/dir.
2025-07-24 10:19:55 -06:00
598f4e5df8 test dir cleanup: rename test/0001/dd79c8cfb8beeacd0460429944b4ecbe comment
Renaming everything also means they have new creation-times, since we're
now git logging a different file/dir.
2025-07-24 10:19:50 -06:00
05c7c6f441 test dir cleanup: rename test/0001/dd79c8cfb8beeacd0460429944b4ecbe
Renaming everything also means they have new creation-times, since we're
now git logging a different file/dir.

fixup test/0001/dd79c8cfb8beeacd0460429944b4ecbe, no comment yet
2025-07-24 10:19:45 -06:00
694d127638 test dir cleanup: rename test/0001/3fa5bfd93317ad25772680071d5ac325
Renaming everything also means they have new creation-times, since we're
now git logging a different file/dir.
2025-07-24 10:19:40 -06:00
4683760942 test dir cleanup: rename test/0000/7792b063eef6d33e7da5dc1856750c14
Renaming everything also means they have new creation-times, since we're
now git logging a different file/dir.
2025-07-24 10:19:33 -06:00
ef8a648cf8 test dir cleanup: rename test/0000/3943fc5c173fdf41c0a22251593cd476
Renaming everything also means they have new creation-times, since we're
now git logging a different file/dir.
2025-07-24 10:19:22 -06:00
7abcf2e446 sort issue tags
This will be useful testing (and general consistency) when tags are
files in a directory instead of lines in a file, and thus subject to
random directory order.
2025-07-23 18:48:04 -06:00
e1287514f6 switch to pretty_assertions, makes it much easier to tell what blew up 2025-07-23 18:02:54 -06:00
6b27a560e3 Merge pull request 'speed-test' (#32) from speed-test into main
Reviewed-on: #32
2025-07-22 20:17:11 -06:00
eb7ac21ac8 half as many git log calls when reading a comment 2025-07-22 13:13:59 -06:00
c15736259c add git::git_log_oldest_author_timestamp(), saves us one git log
This cuts about 30% off the time to read the issues from
entomologist-data.
2025-07-22 13:04:54 -06:00
def729d43a ent done-time: speed up setting of done-time 2025-07-22 10:41:25 -06:00
e2a7c81a13 ent assign: speed up setting of assignee 2025-07-22 10:41:25 -06:00
cc1b378346 ent tag: speed up adding/removing tag 2025-07-22 10:41:25 -06:00
452671d272 add time-ent tool to measure runtime of different ent commands 2025-07-22 10:41:22 -06:00
seb
cfe49adca9 Merge pull request 'sort dependencies alphabetically after reading them' (#31) from sort-dependencies into main
Reviewed-on: #31
2025-07-22 09:59:45 -06:00
f60dd18c0a sort dependencies alphabetically after reading them
This is mostly to make the tests reliable.

Without this the dependencies are inserted into the vector in directory
order, which in my checkout of the repo did not match the alphabetical
order of the dependencies in the test.
2025-07-20 22:27:48 -06:00
5c1d3b1cd5 Merge pull request 'remove Todo file, we have entomologist-data now' (#30) from remove-todo into main
Reviewed-on: #30
2025-07-20 12:43:17 -06:00
44a6ac5110 Merge pull request 'ent done-time ISSUE TIME: report parse error instead of panicking' (#28) from fix-done-time-parsing into main
Reviewed-on: #28
2025-07-20 12:39:58 -06:00
2d8d0db4cb Merge pull request 'include tags in ent show' (#29) from ent-show-tags into main
Reviewed-on: #29
2025-07-20 12:38:02 -06:00
930115813c Merge pull request 'add a tools directory including a "done-last-week" script' (#27) from done-last-week into main
Reviewed-on: #27
2025-07-20 12:28:26 -06:00
seb
24a99d2957 Merge pull request 'Add Dependency Management' (#25) from 03/dependencies-api into main
Reviewed-on: #25
2025-07-20 12:21:56 -06:00
9b8c077653 remove Todo file, we have entomologist-data now 2025-07-20 11:58:54 -06:00
8a92bf2637 fix test directory to match updated dependency representation 2025-07-20 11:55:29 -06:00
c9dbec730c ent show: show tags, if any 2025-07-20 00:04:11 -06:00
0d9a893087 ent show: simplify logic
This simplifies the code flow and gets rid of two levels of indentation.
2025-07-20 00:03:18 -06:00
8af9c71ef6 ent done-time ISSUE TIME: report parse error instead of panicking 2025-07-19 21:10:14 -06:00
8b41f1ebc6 add a tools directory including a "done-last-week" script
This script lists issues that were marked `Done` between "midnight at
the start of the second most recent Monday" and "midnight at the start
of the most recent Monday".

    $ ./tools/done-last-week
    Done:
    8c73c9fd5bc4f551ee5069035ae6e866       migrate the Todo list into entomologist
    75cefad80aacbf23fc7b9c24a75aa236  🗨️ 4  # implement `ent comment ISSUE [DESCRIPTION]` (👉 seb)
    7da3bd5b72de0a05936b094db5d24304  🗨️ 1  implement `ent edit ${COMMENT}` (👉 seb)
    198a7d56a19f0579fbc04f2ee9cc234f       fix ignoring unknown file in issues directory: "README.md"
    e089400e8a9e11fe9bf10d50b2f889d7       add `ent sync` to keep local `entomologist-data` branch in sync with remote
    a26da230276d317e85f9fcca41c19d2e       `ent edit ${ISSUE}` with no change fails (👉 seb)
    317ea8ccac1d414cde55771321bdec30  🗨️ 2  allow multiple read-only ent processes simultaneously (👉 seb)
    da435e5e298b28dc223f9dcfe62a9140       add user control over state transitions (👉 lex)
    fd81241f795333b64e7911cfb1b57c8f       commit messages in the `entomologist-data` branch could be better (👉 seb)
    093e87e8049b93bfa2d8fcd544cae75f       add optional 'assignee' to issue (👉 seb)
    793bda8b9726b0336d97e856895907f8       `ent list` should have a consistent sort order (👉 seb)
    af53c561b36e9b2709b939f81daee534       use git author info to attribute issues and comments to people (👉 seb)
    9e69a30ad6965d7488514584c97ac63c       teach `ent list FILTER` to filter by assignee (👉 seb)
    a5ac277614ea4d13f78031abb25ea7d6       `ent new` and `ent comment`: detect empty issue descriptions & comments (👉 seb)
    7d2d236668872cf11f167ac0462f8751  🗨️ 1  add `ent tag ISSUE [[-]TAG]` (👉 seb)
    54f0eb67b05aa10763c86869ce840f33       `ent sync` should report what changes got fetched & what changes will be pushed (👉 seb)
    4e314a8590864fa76d22758e1785ae35       don't spawn an editor if stdin & stdout aren't a terminal (👉 seb)
    d3a705245bd69aa56524b80b5ae0bc26  🗨️ 1  move IssuesDatabase out of binary and into library (👉 sigil-03)
2025-07-19 20:29:06 -06:00
seb
6a1e438c94 Merge pull request 'skip unparsable issues' (#26) from skip-unparsable-issues into main
Reviewed-on: #26
2025-07-19 20:28:36 -06:00
e79fc4917d Issues::new_from_dir(): move error message to stderr 2025-07-19 10:52:33 -06:00
c217434071 better error handling in comment and git
This replaces a bunch of `unwrap()` calls with error returns.
2025-07-19 10:38:15 -06:00
97a575316e Issues: skip & warn about any Issue that fails to parse
This lets us at least handle the other, valid issues, while informing
the user about the ones we don't understand.
2025-07-19 09:55:32 -06:00
2ba13ebaeb Issue: get rid of all unwraps
Make and return errors instead.
2025-07-19 09:55:32 -06:00
8319a4f118 add dependency API / fix dependency representation / dependency
management via CLI
2025-07-18 16:20:17 -06:00
seb
04b33eb70f Merge pull request 'completed Issues now know when they were marked Done' (#24) from done-time into main
Reviewed-on: #24
2025-07-18 10:21:42 -06:00
d3ba28f4d4 Merge pull request 'CLI: Print ID on new' (#23) from 03/print-id-on-new into main
Reviewed-on: #23
2025-07-17 12:02:42 -06:00
3e0ab7092e update CLI to print the issue ID when a new issue is created 2025-07-17 12:00:26 -06:00
a3077ca313 ent list FILTER: add filter "done-time=[START]..[END]" 2025-07-16 21:59:17 -06:00
bc2b1bd3c1 add API and CLI to get & set done-time of an issue 2025-07-16 21:51:27 -06:00
3b33ed41f5 Issue: add done_time field
This records the DateTime that the issue moved to the Done state (if any).
2025-07-16 21:50:44 -06:00
3df76b89df rename Issue and Comment timestamp to creation_time
This is to make room for a second timestamp that records when the issue
was marked Done.
2025-07-16 21:50:44 -06:00
20c17f281b ent list FILTER: the filter now takes multiple strings
This is instead of a single big string with chunks separated by ":".

":" is used in RFC 3339 date-time strings (like "2025-07-16 21:23:44
-06:00"), so it's inconvenient to reserve ":" to be the chunk separator.

I'm not super wedded to this new Vec<String> way of doing the filter,
but it seems fine and convenient for now.
2025-07-16 21:50:44 -06:00
5e5508a2ee Issue: make a helper function to commit an Issue
This improves code reuse and streamlines the code a bit.
2025-07-16 21:50:42 -06:00
seb
e0d9d45a6a Merge pull request 'Add ID field to Issue Struct' (#22) from 03/move-id-into-issue into main
Reviewed-on: #22
2025-07-15 15:04:38 -06:00
733100fefb add the ID field back into the Issue struct 2025-07-15 10:53:52 -06:00
33 changed files with 829 additions and 397 deletions

View file

@ -7,6 +7,9 @@ edition = "2024"
default = [] default = []
log = ["dep:log", "dep:simple_logger"] log = ["dep:log", "dep:simple_logger"]
[dev-dependencies]
pretty_assertions = "1.4.1"
[dependencies] [dependencies]
anyhow = "1.0.95" anyhow = "1.0.95"
chrono = "0.4.41" chrono = "0.4.41"

19
Todo.md
View file

@ -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

View file

@ -24,22 +24,31 @@ struct Args {
enum Commands { enum Commands {
/// List issues. /// List issues.
List { List {
/// Filter string, describes issues to include in the list. /// Filter strings, describes issues to include in the list.
/// The filter string is composed of chunks separated by ":". /// Each filter string is of the form "name=condition".
/// Each chunk is of the form "name=condition". The supported /// The supported names and their matching conditions are:
/// names and their matching conditions are:
/// ///
/// "state": Comma-separated list of states to list. /// "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. /// "assignee": Comma-separated list of assignees to include in
/// Defaults to all assignees if not set. /// 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 /// "tag": Comma-separated list of tags to include, or exclude
/// (if prefixed with "-"). If omitted, defaults to including /// if prefixed with "-". Example: "tag=bug,-docs" shows issues
/// all tags and excluding none. /// 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"))] /// "done-time": Time range of issue completion, in the form
filter: String, /// "[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<String>,
}, },
/// Create a new issue. /// Create a new issue.
@ -84,6 +93,18 @@ enum Commands {
#[arg(allow_hyphen_values = true)] #[arg(allow_hyphen_values = true)]
tag: Option<String>, tag: Option<String>,
}, },
/// Get or set the `done_time` of the Issue.
DoneTime {
issue_id: String,
done_time: Option<String>,
},
/// get or add a dependency to the issue
Depend {
issue_id: String,
dependency_id: Option<String>,
},
} }
fn handle_command( fn handle_command(
@ -93,7 +114,14 @@ fn handle_command(
match &args.command { match &args.command {
Commands::List { filter } => { Commands::List { filter } => {
let issues = entomologist::database::read_issues_database(issues_database_source)?; 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::< let mut uuids_by_state = std::collections::HashMap::<
entomologist::issue::State, entomologist::issue::State,
Vec<&entomologist::issue::IssueHandle>, 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. // This issue passed all the filters, include it in list.
uuids_by_state uuids_by_state
.entry(issue.state.clone()) .entry(issue.state.clone())
@ -146,7 +187,7 @@ fn handle_command(
these_uuids.sort_by(|a_id, b_id| { these_uuids.sort_by(|a_id, b_id| {
let a = issues.issues.get(*a_id).unwrap(); let a = issues.issues.get(*a_id).unwrap();
let b = issues.issues.get(*b_id).unwrap(); let b = issues.issues.get(*b_id).unwrap();
a.timestamp.cmp(&b.timestamp) a.creation_time.cmp(&b.creation_time)
}); });
println!("{:?}:", state); println!("{:?}:", state);
for uuid in these_uuids { for uuid in these_uuids {
@ -191,8 +232,10 @@ fn handle_command(
} }
Commands::New { description } => { Commands::New { description } => {
let issues_database = let issues_database = entomologist::database::make_issues_database(
entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; issues_database_source,
entomologist::database::IssuesDatabaseAccess::ReadWrite,
)?;
match entomologist::issue::Issue::new(&issues_database.dir, description) { match entomologist::issue::Issue::new(&issues_database.dir, description) {
Err(entomologist::issue::IssueError::EmptyDescription) => { Err(entomologist::issue::IssueError::EmptyDescription) => {
println!("no new issue created"); println!("no new issue created");
@ -203,14 +246,17 @@ fn handle_command(
} }
Ok(issue) => { Ok(issue) => {
println!("created new issue '{}'", issue.title()); println!("created new issue '{}'", issue.title());
println!("ID: {}", issue.id);
return Ok(()); return Ok(());
} }
} }
} }
Commands::Edit { uuid } => { Commands::Edit { uuid } => {
let issues_database = let issues_database = entomologist::database::make_issues_database(
entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; issues_database_source,
entomologist::database::IssuesDatabaseAccess::ReadWrite,
)?;
let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?;
if let Some(issue) = issues.get_mut_issue(uuid) { if let Some(issue) = issues.get_mut_issue(uuid) {
match issue.edit_description() { match issue.edit_description() {
@ -245,32 +291,40 @@ fn handle_command(
Commands::Show { issue_id } => { Commands::Show { issue_id } => {
let issues = entomologist::database::read_issues_database(issues_database_source)?; let issues = entomologist::database::read_issues_database(issues_database_source)?;
match issues.get_issue(issue_id) { let Some(issue) = issues.get_issue(issue_id) else {
Some(issue) => { return Err(anyhow::anyhow!("issue {} not found", issue_id));
println!("issue {}", issue_id); };
println!("author: {}", issue.author); println!("issue {}", issue_id);
println!("timestamp: {}", issue.timestamp); println!("author: {}", issue.author);
println!("state: {:?}", issue.state); if issue.tags.len() > 0 {
if let Some(dependencies) = &issue.dependencies { print!("tags: ");
println!("dependencies: {:?}", dependencies); let mut separator = "";
} for tag in &issue.tags {
if let Some(assignee) = &issue.assignee { print!("{}{}", separator, tag);
println!("assignee: {}", assignee); separator = ", ";
}
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));
} }
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, new_state,
} => match new_state { } => match new_state {
Some(new_state) => { Some(new_state) => {
let issues_database = let issues_database = entomologist::database::make_issues_database(
entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; issues_database_source,
entomologist::database::IssuesDatabaseAccess::ReadWrite,
)?;
let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?;
match issues.issues.get_mut(issue_id) { match issues.issues.get_mut(issue_id) {
Some(issue) => { Some(issue) => {
@ -312,8 +368,10 @@ fn handle_command(
issue_id, issue_id,
description, description,
} => { } => {
let issues_database = let issues_database = entomologist::database::make_issues_database(
entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; issues_database_source,
entomologist::database::IssuesDatabaseAccess::ReadWrite,
)?;
let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?;
let Some(issue) = issues.get_mut_issue(issue_id) else { let Some(issue) = issues.get_mut_issue(issue_id) else {
return Err(anyhow::anyhow!("issue {} not found", issue_id)); return Err(anyhow::anyhow!("issue {} not found", issue_id));
@ -338,9 +396,13 @@ fn handle_command(
} }
Commands::Sync { remote } => { Commands::Sync { remote } => {
if let entomologist::database::IssuesDatabaseSource::Branch(branch) = issues_database_source { if let entomologist::database::IssuesDatabaseSource::Branch(branch) =
let issues_database = issues_database_source
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,
)?;
entomologist::git::sync(&issues_database.dir, remote, branch)?; entomologist::git::sync(&issues_database.dir, remote, branch)?;
println!("synced {:?} with {:?}", branch, remote); println!("synced {:?} with {:?}", branch, remote);
} else { } else {
@ -353,80 +415,151 @@ fn handle_command(
Commands::Assign { Commands::Assign {
issue_id, issue_id,
new_assignee, new_assignee,
} => { } => match new_assignee {
let issues = entomologist::database::read_issues_database(issues_database_source)?; Some(new_assignee) => {
let Some(original_issue) = issues.issues.get(issue_id) else { let issues_database = entomologist::database::make_issues_database(
return Err(anyhow::anyhow!("issue {} not found", issue_id)); issues_database_source,
}; entomologist::database::IssuesDatabaseAccess::ReadWrite,
let old_assignee: String = match &original_issue.assignee { )?;
Some(assignee) => assignee.clone(), let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?;
None => String::from("None"), let Some(issue) = issues.get_mut_issue(issue_id) else {
}; return Err(anyhow::anyhow!("issue {} not found", issue_id));
println!("issue: {}", issue_id); };
match new_assignee { let old_assignee: String = match &issue.assignee {
Some(new_assignee) => { Some(assignee) => assignee.clone(),
let issues_database = entomologist::database::make_issues_database( None => String::from("None"),
issues_database_source, };
entomologist::database::IssuesDatabaseAccess::ReadWrite, issue.set_assignee(new_assignee)?;
)?; println!("issue: {}", issue_id);
let mut issues = println!("assignee: {} -> {}", old_assignee, new_assignee);
entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; }
let Some(issue) = issues.get_mut_issue(issue_id) else { None => {
return Err(anyhow::anyhow!("issue {} not found", issue_id)); let issues = entomologist::database::read_issues_database(issues_database_source)?;
}; let Some(original_issue) = issues.issues.get(issue_id) else {
println!("assignee: {} -> {}", old_assignee, new_assignee); return Err(anyhow::anyhow!("issue {} not found", issue_id));
issue.set_assignee(new_assignee)?; };
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 => { let issues_database = entomologist::database::make_issues_database(
println!("assignee: {}", old_assignee); 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.
Commands::Tag { issue_id, tag } => { let issues = entomologist::database::read_issues_database(issues_database_source)?;
let issues = entomologist::database::read_issues_database(issues_database_source)?; let Some(issue) = issues.issues.get(issue_id) else {
let Some(issue) = issues.issues.get(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id));
return Err(anyhow::anyhow!("issue {} not found", issue_id)); };
}; match &issue.tags.len() {
match tag { 0 => println!("no tags"),
Some(tag) => { _ => {
// Add or remove tag. // Could use `format!(" {:?}", issue.tags)`
let issues_database = entomologist::database::make_issues_database( // here, but that results in `["tag1", "TAG2",
issues_database_source, // "i-am-also-a-tag"]` and i don't want the
entomologist::database::IssuesDatabaseAccess::ReadWrite, // double-quotes around each tag.
)?; for tag in &issue.tags {
let mut issues = println!("{}", tag);
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);
}
} }
} }
} }
} }
} },
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(()) Ok(())
@ -440,13 +573,15 @@ fn main() -> anyhow::Result<()> {
// println!("{:?}", args); // println!("{:?}", args);
let issues_database_source = match (&args.issues_dir, &args.issues_branch) { 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, Some(branch)) => entomologist::database::IssuesDatabaseSource::Branch(branch),
(None, None) => entomologist::database::IssuesDatabaseSource::Branch("entomologist-data"), (None, None) => entomologist::database::IssuesDatabaseSource::Branch("entomologist-data"),
(Some(_), Some(_)) => { (Some(_), Some(_)) => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"don't specify both `--issues-dir` and `--issues-branch`" "don't specify both `--issues-dir` and `--issues-branch`"
)) ));
} }
}; };

View file

@ -4,7 +4,7 @@ use std::io::{IsTerminal, Write};
pub struct Comment { pub struct Comment {
pub uuid: String, pub uuid: String,
pub author: String, pub author: String,
pub timestamp: chrono::DateTime<chrono::Local>, pub creation_time: chrono::DateTime<chrono::Local>,
pub description: String, pub description: String,
/// This is the directory that the comment lives in. Only used /// 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); 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); let dir = std::path::PathBuf::from(comment_dir);
Ok(Self { 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, author,
timestamp, creation_time,
description: description.unwrap(), description,
dir: std::path::PathBuf::from(comment_dir), dir: std::path::PathBuf::from(comment_dir),
}) })
} }
@ -84,7 +88,7 @@ impl Comment {
let mut comment = crate::comment::Comment { let mut comment = crate::comment::Comment {
uuid, uuid,
author: String::from(""), // this will be updated from git when we re-read this comment 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 description: String::from(""), // this will be set immediately below
dir: dir.clone(), dir: dir.clone(),
}; };
@ -109,7 +113,11 @@ impl Comment {
&format!( &format!(
"add comment {} on issue {}", "add comment {} on issue {}",
comment.uuid, 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)?; crate::git::add(&description_filename)?;
if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? {
crate::git::commit( crate::git::commit(
&description_filename.parent().unwrap(), &description_filename
.parent()
.ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?,
&format!( &format!(
"edit comment {} on issue FIXME", // FIXME: name the issue that the comment is on "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()?; self.read_description()?;
@ -165,8 +178,8 @@ impl Comment {
.spawn()? .spawn()?
.wait_with_output()?; .wait_with_output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(CommentError::EditorError); return Err(CommentError::EditorError);
} }
@ -195,19 +208,21 @@ impl Comment {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use pretty_assertions::assert_eq;
#[test] #[test]
fn read_comment_0() { fn read_comment_0() {
let comment_dir = let comment_dir = std::path::Path::new(
std::path::Path::new("test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347"); "test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347",
);
let comment = Comment::new_from_dir(comment_dir).unwrap(); let comment = Comment::new_from_dir(comment_dir).unwrap();
let expected = Comment { let expected = Comment {
uuid: String::from("9055dac36045fe36545bed7ae7b49347"), uuid: String::from("9055dac36045fe36545bed7ae7b49347"),
author: String::from("Sebastian Kuzminsky <seb@highlab.com>"), author: String::from("Sebastian Kuzminsky <seb@highlab.com>"),
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() .unwrap()
.with_timezone(&chrono::Local), .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), dir: std::path::PathBuf::from(comment_dir),
}; };
assert_eq!(comment, expected); assert_eq!(comment, expected);

View file

@ -48,8 +48,8 @@ impl Worktree {
.args(["worktree", "add", &path.path().to_string_lossy(), branch]) .args(["worktree", "add", &path.path().to_string_lossy(), branch])
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
Ok(Self { path }) Ok(Self { path })
@ -67,8 +67,8 @@ impl Worktree {
]) ])
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
Ok(Self { path }) Ok(Self { path })
@ -87,8 +87,8 @@ pub fn checkout_branch_in_worktree(
.args(["worktree", "add", &worktree_dir.to_string_lossy(), branch]) .args(["worktree", "add", &worktree_dir.to_string_lossy(), branch])
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
Ok(()) Ok(())
@ -99,8 +99,8 @@ pub fn git_worktree_prune() -> Result<(), GitError> {
.args(["worktree", "prune"]) .args(["worktree", "prune"])
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
Ok(()) Ok(())
@ -111,8 +111,8 @@ pub fn git_remove_branch(branch: &str) -> Result<(), GitError> {
.args(["branch", "-D", branch]) .args(["branch", "-D", branch])
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
Ok(()) Ok(())
@ -139,11 +139,14 @@ pub fn worktree_is_dirty(dir: &str) -> Result<bool, GitError> {
pub fn add(file: &std::path::Path) -> Result<(), GitError> { pub fn add(file: &std::path::Path) -> Result<(), GitError> {
let result = std::process::Command::new("git") let result = std::process::Command::new("git")
.args(["add", &file.to_string_lossy()]) .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()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
return Ok(()); 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> { pub fn restore_file(file: &std::path::Path) -> Result<(), GitError> {
let result = std::process::Command::new("git") let result = std::process::Command::new("git")
.args(["restore", &file.to_string_lossy()]) .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()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
return Ok(()); return Ok(());
@ -168,8 +174,8 @@ pub fn commit(dir: &std::path::Path, msg: &str) -> Result<(), GitError> {
.current_dir(dir) .current_dir(dir)
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
Ok(()) Ok(())
@ -180,12 +186,18 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> {
git_dir.pop(); git_dir.pop();
let result = std::process::Command::new("git") 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) .current_dir(&git_dir)
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
@ -195,15 +207,20 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> {
"-m", "-m",
&format!( &format!(
"update '{}' in issue {}", "update '{}' in issue {}",
file.file_name().unwrap().to_string_lossy(), file.file_name()
git_dir.file_name().unwrap().to_string_lossy() .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) .current_dir(&git_dir)
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
@ -216,8 +233,8 @@ pub fn git_fetch(dir: &std::path::Path, remote: &str) -> Result<(), GitError> {
.current_dir(dir) .current_dir(dir)
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
Ok(()) 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 {:?}", "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}",
branch branch
); );
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
if result.stdout.len() > 0 { if result.stdout.len() > 0 {
println!("Changes fetched from remote {}:", remote); println!("Changes fetched from remote {}:", remote);
println!("{}", std::str::from_utf8(&result.stdout).unwrap()); println!("{}", &String::from_utf8_lossy(&result.stdout));
println!(""); 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 {:?}", "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}",
branch branch
); );
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
if result.stdout.len() > 0 { if result.stdout.len() > 0 {
println!("Changes to push to remote {}:", remote); println!("Changes to push to remote {}:", remote);
println!("{}", std::str::from_utf8(&result.stdout).unwrap()); println!("{}", &String::from_utf8_lossy(&result.stdout));
println!(""); 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 {:?}", "Sync failed! Merge error! Help, a human needs to fix the mess in {:?}",
branch branch
); );
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); 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 {:?}", "Sync failed! Push error! Help, a human needs to fix the mess in {:?}",
branch branch
); );
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
@ -332,13 +349,16 @@ pub fn git_log_oldest_timestamp(
"log", "log",
"--pretty=format:%at", "--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) .current_dir(&git_dir)
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); 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<String, GitError>
"log", "log",
"--pretty=format:%an <%ae>", "--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) .current_dir(&git_dir)
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
let author_str = std::str::from_utf8(&result.stdout).unwrap(); 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<String, GitError>
Ok(String::from(author_last)) Ok(String::from(author_last))
} }
pub fn git_log_oldest_author_timestamp(
path: &std::path::Path,
) -> Result<(String, chrono::DateTime<chrono::Local>), 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::<i64>()?;
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> { pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> {
{ {
let tmp_worktree = tempfile::tempdir().unwrap(); let tmp_worktree = tempfile::tempdir().unwrap();
@ -383,8 +446,8 @@ pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> {
.args(["worktree", "prune"]) .args(["worktree", "prune"])
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
@ -400,8 +463,8 @@ fn create_orphan_branch_at_path(
.args(["worktree", "add", "--orphan", "-b", branch, &worktree_dir]) .args(["worktree", "add", "--orphan", "-b", branch, &worktree_dir])
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
@ -418,8 +481,8 @@ fn create_orphan_branch_at_path(
.current_dir(worktree_path) .current_dir(worktree_path)
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
@ -428,8 +491,8 @@ fn create_orphan_branch_at_path(
.current_dir(worktree_path) .current_dir(worktree_path)
.output()?; .output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(GitError::Oops); return Err(GitError::Oops);
} }
@ -439,6 +502,7 @@ fn create_orphan_branch_at_path(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use pretty_assertions::assert_eq;
#[test] #[test]
fn test_worktree() { fn test_worktree() {

View file

@ -20,8 +20,10 @@ pub type IssueHandle = String;
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub struct Issue { pub struct Issue {
pub id: String,
pub author: String, pub author: String,
pub timestamp: chrono::DateTime<chrono::Local>, pub creation_time: chrono::DateTime<chrono::Local>,
pub done_time: Option<chrono::DateTime<chrono::Local>>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub state: State, pub state: State,
pub dependencies: Option<Vec<IssueHandle>>, pub dependencies: Option<Vec<IssueHandle>>,
@ -42,6 +44,8 @@ pub enum IssueError {
EnvVarError(#[from] std::env::VarError), EnvVarError(#[from] std::env::VarError),
#[error(transparent)] #[error(transparent)]
CommentError(#[from] crate::comment::CommentError), CommentError(#[from] crate::comment::CommentError),
#[error(transparent)]
ChronoParseError(#[from] chrono::format::ParseError),
#[error("Failed to parse issue")] #[error("Failed to parse issue")]
IssueParseError, IssueParseError,
#[error("Failed to parse state")] #[error("Failed to parse state")]
@ -56,6 +60,14 @@ pub enum IssueError {
TagNotFound(String), TagNotFound(String),
#[error("stdin/stdout is not a terminal")] #[error("stdin/stdout is not a terminal")]
StdioIsNotTerminal, 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 { impl FromStr for State {
@ -103,6 +115,7 @@ impl Issue {
let mut comments = Vec::<crate::comment::Comment>::new(); let mut comments = Vec::<crate::comment::Comment>::new();
let mut assignee: Option<String> = None; let mut assignee: Option<String> = None;
let mut tags = Vec::<String>::new(); let mut tags = Vec::<String>::new();
let mut done_time: Option<chrono::DateTime<chrono::Local>> = None;
for direntry in dir.read_dir()? { for direntry in dir.read_dir()? {
if let Ok(direntry) = direntry { if let Ok(direntry) = direntry {
@ -116,15 +129,13 @@ impl Issue {
assignee = Some(String::from( assignee = Some(String::from(
std::fs::read_to_string(direntry.path())?.trim(), std::fs::read_to_string(direntry.path())?.trim(),
)); ));
} else if file_name == "dependencies" { } else if file_name == "done_time" {
let dep_strings = std::fs::read_to_string(direntry.path())?; let raw_done_time = chrono::DateTime::<_>::parse_from_rfc3339(
let deps: Vec<IssueHandle> = dep_strings std::fs::read_to_string(direntry.path())?.trim(),
.lines() )?;
.map(|dep| IssueHandle::from(dep)) done_time = Some(raw_done_time.into());
.collect(); } else if file_name == "dependencies" && direntry.metadata()?.is_dir() {
if deps.len() > 0 { dependencies = Self::read_dependencies(&direntry.path())?;
dependencies = Some(deps);
}
} else if file_name == "tags" { } else if file_name == "tags" {
let contents = std::fs::read_to_string(direntry.path())?; let contents = std::fs::read_to_string(direntry.path())?;
tags = contents tags = contents
@ -132,6 +143,7 @@ impl Issue {
.filter(|s| s.len() > 0) .filter(|s| s.len() > 0)
.map(|tag| String::from(tag.trim())) .map(|tag| String::from(tag.trim()))
.collect(); .collect();
tags.sort();
} else if file_name == "comments" && direntry.metadata()?.is_dir() { } else if file_name == "comments" && direntry.metadata()?.is_dir() {
Self::read_comments(&mut comments, &direntry.path())?; Self::read_comments(&mut comments, &direntry.path())?;
} else { } else {
@ -141,21 +153,32 @@ impl Issue {
} }
} }
if description == None { let Some(description) = description else {
return Err(IssueError::IssueParseError); return Err(IssueError::IssueParseError);
} };
let author = crate::git::git_log_oldest_author(dir)?; // parse the issue ID from the directory name
let timestamp = crate::git::git_log_oldest_timestamp(dir)?; 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 { Ok(Self {
id,
author, author,
timestamp, creation_time,
done_time,
tags, tags,
state: state, state: state,
dependencies, dependencies,
assignee, assignee,
description: description.unwrap(), description,
comments, comments,
dir: std::path::PathBuf::from(dir), dir: std::path::PathBuf::from(dir),
}) })
@ -171,10 +194,30 @@ impl Issue {
comments.push(comment); 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(()) Ok(())
} }
fn read_dependencies(dir: &std::path::Path) -> Result<Option<Vec<IssueHandle>>, IssueError> {
let mut dependencies: Option<Vec<String>> = 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. /// Add a new Comment to the Issue. Commits.
pub fn add_comment( pub fn add_comment(
&mut self, &mut self,
@ -204,8 +247,10 @@ impl Issue {
std::fs::create_dir(&issue_dir)?; std::fs::create_dir(&issue_dir)?;
let mut issue = Self { let mut issue = Self {
id: String::from(&issue_id),
author: String::from(""), author: String::from(""),
timestamp: chrono::Local::now(), creation_time: chrono::Local::now(),
done_time: None,
tags: Vec::<String>::new(), tags: Vec::<String>::new(),
state: State::New, state: State::New,
dependencies: None, dependencies: None,
@ -228,8 +273,7 @@ impl Issue {
None => issue.edit_description_file()?, None => issue.edit_description_file()?,
}; };
crate::git::add(&issue_dir)?; issue.commit(&format!("create new issue {}", issue_id))?;
crate::git::commit(&issue_dir, &format!("create new issue {}", issue_id))?;
Ok(issue) Ok(issue)
} }
@ -238,21 +282,15 @@ impl Issue {
pub fn edit_description(&mut self) -> Result<(), IssueError> { pub fn edit_description(&mut self) -> Result<(), IssueError> {
self.edit_description_file()?; self.edit_description_file()?;
let description_filename = self.description_filename(); let description_filename = self.description_filename();
crate::git::add(&description_filename)?; self.commit(&format!(
if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { "edit description of issue {}",
crate::git::commit( description_filename
&description_filename.parent().unwrap(), .parent()
&format!( .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?
"edit description of issue {}", .file_name()
description_filename .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?
.parent() .to_string_lossy(),
.unwrap() ))?;
.file_name()
.unwrap()
.to_string_lossy()
),
)?;
}
Ok(()) 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> { pub fn set_state(&mut self, new_state: State) -> Result<(), IssueError> {
let old_state = self.state.clone(); let old_state = self.state.clone();
let mut state_filename = std::path::PathBuf::from(&self.dir); let mut state_filename = std::path::PathBuf::from(&self.dir);
state_filename.push("state"); state_filename.push("state");
let mut state_file = std::fs::File::create(&state_filename)?; let mut state_file = std::fs::File::create(&state_filename)?;
write!(state_file, "{}", new_state)?; write!(state_file, "{}", new_state)?;
crate::git::add(&state_filename)?; self.commit(&format!(
if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { "change state of issue {}, {} -> {}",
crate::git::commit( self.dir
&self.dir, .file_name()
&format!( .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?
"change state of issue {}, {} -> {}", .to_string_lossy(),
self.dir.file_name().unwrap().to_string_lossy(), old_state,
old_state, new_state,
new_state, ))?;
), if new_state == State::Done {
)?; self.set_done_time(chrono::Local::now())?;
} }
Ok(()) Ok(())
} }
@ -294,6 +333,27 @@ impl Issue {
Ok(()) Ok(())
} }
/// Set the `done_time` of the Issue. Commits.
pub fn set_done_time(
&mut self,
done_time: chrono::DateTime<chrono::Local>,
) -> 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. /// Set the Assignee of an Issue.
pub fn set_assignee(&mut self, new_assignee: &str) -> Result<(), IssueError> { pub fn set_assignee(&mut self, new_assignee: &str) -> Result<(), IssueError> {
let old_assignee = match &self.assignee { let old_assignee = match &self.assignee {
@ -304,18 +364,15 @@ impl Issue {
assignee_filename.push("assignee"); assignee_filename.push("assignee");
let mut assignee_file = std::fs::File::create(&assignee_filename)?; let mut assignee_file = std::fs::File::create(&assignee_filename)?;
write!(assignee_file, "{}", new_assignee)?; write!(assignee_file, "{}", new_assignee)?;
crate::git::add(&assignee_filename)?; self.commit(&format!(
if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { "change assignee of issue {}, {} -> {}",
crate::git::commit( self.dir
&self.dir, .file_name()
&format!( .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?
"change assignee of issue {}, {} -> {}", .to_string_lossy(),
self.dir.file_name().unwrap().to_string_lossy(), old_assignee,
old_assignee, new_assignee,
new_assignee, ))?;
),
)?;
}
Ok(()) Ok(())
} }
@ -329,7 +386,10 @@ impl Issue {
self.tags.sort(); self.tags.sort();
self.commit_tags(&format!( self.commit_tags(&format!(
"issue {} add tag {}", "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 tag
))?; ))?;
Ok(()) Ok(())
@ -344,7 +404,10 @@ impl Issue {
self.tags.remove(index); self.tags.remove(index);
self.commit_tags(&format!( self.commit_tags(&format!(
"issue {} remove tag {}", "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 tag
))?; ))?;
Ok(()) Ok(())
@ -363,6 +426,46 @@ impl Issue {
} }
return false; 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. // This is the internal/private API of Issue.
@ -403,8 +506,8 @@ impl Issue {
.spawn()? .spawn()?
.wait_with_output()?; .wait_with_output()?;
if !result.status.success() { if !result.status.success() {
println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout));
println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr));
return Err(IssueError::EditorError); return Err(IssueError::EditorError);
} }
if !description_filename.exists() || description_filename.metadata()?.len() == 0 { if !description_filename.exists() || description_filename.metadata()?.len() == 0 {
@ -429,7 +532,15 @@ impl Issue {
for tag in &self.tags { for tag in &self.tags {
writeln!(tags_file, "{}", tag)?; 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)?; crate::git::commit(&self.dir, commit_message)?;
Ok(()) Ok(())
} }
@ -438,25 +549,30 @@ impl Issue {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use pretty_assertions::assert_eq;
#[test] #[test]
fn read_issue_0() { 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 issue = Issue::new_from_dir(issue_dir).unwrap();
let expected = Issue { let expected = Issue {
id: String::from("3943fc5c173fdf41c0a22251593cd476"),
author: String::from("Sebastian Kuzminsky <seb@highlab.com>"), author: String::from("Sebastian Kuzminsky <seb@highlab.com>"),
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() .unwrap()
.with_timezone(&chrono::Local), .with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::from([ tags: Vec::<String>::from([
String::from("tag1"),
String::from("TAG2"), String::from("TAG2"),
String::from("i-am-also-a-tag") String::from("i-am-also-a-tag"),
String::from("tag1"),
]), ]),
state: State::New, state: State::New,
dependencies: None, dependencies: None,
assignee: 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::<crate::comment::Comment>::new(), comments: Vec::<crate::comment::Comment>::new(),
dir: std::path::PathBuf::from(issue_dir), dir: std::path::PathBuf::from(issue_dir),
}; };
@ -465,13 +581,15 @@ mod tests {
#[test] #[test]
fn read_issue_1() { 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 issue = Issue::new_from_dir(issue_dir).unwrap();
let expected = Issue { let expected = Issue {
id: String::from("7792b063eef6d33e7da5dc1856750c14"),
author: String::from("Sebastian Kuzminsky <seb@highlab.com>"), author: String::from("Sebastian Kuzminsky <seb@highlab.com>"),
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() .unwrap()
.with_timezone(&chrono::Local), .with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::new(), tags: Vec::<String>::new(),
state: State::InProgress, state: State::InProgress,
dependencies: None, dependencies: None,

View file

@ -31,8 +31,8 @@ impl Issues {
} }
} }
pub fn add_issue(&mut self, uuid: String, issue: crate::issue::Issue) { pub fn add_issue(&mut self, issue: crate::issue::Issue) {
self.issues.insert(uuid, issue); self.issues.insert(issue.id.clone(), issue);
} }
pub fn get_issue(&self, issue_id: &str) -> Option<&crate::issue::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()? { for direntry in dir.read_dir()? {
if let Ok(direntry) = direntry { if let Ok(direntry) = direntry {
if direntry.metadata()?.is_dir() { if direntry.metadata()?.is_dir() {
let uuid = match direntry.file_name().into_string() { match crate::issue::Issue::new_from_dir(direntry.path().as_path()) {
Ok(uuid) => uuid, Err(e) => {
Err(orig_string) => { eprintln!(
return Err(ReadIssuesError::FilenameError(orig_string)) "failed to parse issue {}, skipping",
direntry.file_name().to_string_lossy()
);
eprintln!("ignoring error: {:?}", e);
continue;
} }
}; Ok(issue) => {
let issue = crate::issue::Issue::new_from_dir(direntry.path().as_path())?; issues.add_issue(issue);
issues.add_issue(uuid, issue); }
}
} else if direntry.file_name() == "config.toml" { } else if direntry.file_name() == "config.toml" {
issues.parse_config(direntry.path().as_path())?; issues.parse_config(direntry.path().as_path())?;
} else { } else {
@ -82,6 +87,7 @@ impl Issues {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use pretty_assertions::assert_eq;
#[test] #[test]
fn read_issues_0000() { fn read_issues_0000() {
@ -90,40 +96,40 @@ mod tests {
let mut expected = Issues::new(); let mut expected = Issues::new();
let uuid = String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"); let uuid = String::from("7792b063eef6d33e7da5dc1856750c14");
let mut dir = std::path::PathBuf::from(issues_dir); let mut dir = std::path::PathBuf::from(issues_dir);
dir.push(&uuid); dir.push(&uuid);
expected.add_issue( expected.add_issue(crate::issue::Issue {
uuid, id: uuid,
crate::issue::Issue { author: String::from("Sebastian Kuzminsky <seb@highlab.com>"),
author: String::from("Sebastian Kuzminsky <seb@highlab.com>"), creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00")
timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap()
.unwrap() .with_timezone(&chrono::Local),
.with_timezone(&chrono::Local), done_time: None,
tags: Vec::<String>::new(), tags: Vec::<String>::new(),
state: crate::issue::State::InProgress, state: crate::issue::State::InProgress,
dependencies: None, dependencies: None,
assignee: Some(String::from("beep boop")), assignee: Some(String::from("beep boop")),
description: String::from("minimal"), description: String::from("minimal"),
comments: Vec::<crate::comment::Comment>::new(), comments: Vec::<crate::comment::Comment>::new(),
dir, dir,
}, });
);
let uuid = String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"); let uuid = String::from("3943fc5c173fdf41c0a22251593cd476");
let mut dir = std::path::PathBuf::from(issues_dir); let mut dir = std::path::PathBuf::from(issues_dir);
dir.push(&uuid); dir.push(&uuid);
expected.add_issue( expected.add_issue(
uuid,
crate::issue::Issue { crate::issue::Issue {
id: uuid,
author: String::from("Sebastian Kuzminsky <seb@highlab.com>"), author: String::from("Sebastian Kuzminsky <seb@highlab.com>"),
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() .unwrap()
.with_timezone(&chrono::Local), .with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::from([ tags: Vec::<String>::from([
String::from("tag1"),
String::from("TAG2"), 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, state: crate::issue::State::New,
dependencies: None, dependencies: None,
@ -143,27 +149,30 @@ mod tests {
let mut expected = Issues::new(); let mut expected = Issues::new();
let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); let uuid = String::from("3fa5bfd93317ad25772680071d5ac325");
let mut dir = std::path::PathBuf::from(issues_dir); let mut dir = std::path::PathBuf::from(issues_dir);
dir.push(&uuid); dir.push(&uuid);
expected.add_issue( expected.add_issue(crate::issue::Issue {
uuid, id: uuid,
crate::issue::Issue { author: String::from("Sebastian Kuzminsky <seb@highlab.com>"),
author: String::from("Sebastian Kuzminsky <seb@highlab.com>"), creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:46-06:00")
timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap()
.with_timezone(&chrono::Local),
done_time: Some(
chrono::DateTime::parse_from_rfc3339("2025-07-15T15:15:15-06:00")
.unwrap() .unwrap()
.with_timezone(&chrono::Local), .with_timezone(&chrono::Local),
tags: Vec::<String>::new(), ),
state: crate::issue::State::Done, tags: Vec::<String>::new(),
dependencies: None, state: crate::issue::State::Done,
assignee: None, dependencies: None,
description: String::from("oh yeah we got titles"), assignee: None,
comments: Vec::<crate::comment::Comment>::new(), description: String::from("oh yeah we got titles"),
dir, comments: Vec::<crate::comment::Comment>::new(),
}, dir,
); });
let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe");
let mut dir = std::path::PathBuf::from(issues_dir); let mut dir = std::path::PathBuf::from(issues_dir);
dir.push(&uuid); dir.push(&uuid);
let mut comment_dir = dir.clone(); let mut comment_dir = dir.clone();
@ -175,18 +184,19 @@ mod tests {
crate::comment::Comment { crate::comment::Comment {
uuid: comment_uuid, uuid: comment_uuid,
author: String::from("Sebastian Kuzminsky <seb@highlab.com>"), author: String::from("Sebastian Kuzminsky <seb@highlab.com>"),
timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), 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), dir: std::path::PathBuf::from(comment_dir),
} }
); );
expected.add_issue( expected.add_issue(
uuid,
crate::issue::Issue { crate::issue::Issue {
id: uuid,
author: String::from("Sebastian Kuzminsky <seb@highlab.com>"), author: String::from("Sebastian Kuzminsky <seb@highlab.com>"),
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() .unwrap()
.with_timezone(&chrono::Local), .with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::new(), tags: Vec::<String>::new(),
state: crate::issue::State::WontDo, state: crate::issue::State::WontDo,
dependencies: None, dependencies: None,
@ -206,36 +216,36 @@ mod tests {
let mut expected = Issues::new(); let mut expected = Issues::new();
let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); let uuid = String::from("3fa5bfd93317ad25772680071d5ac325");
let mut dir = std::path::PathBuf::from(issues_dir); let mut dir = std::path::PathBuf::from(issues_dir);
dir.push(&uuid); dir.push(&uuid);
expected.add_issue( expected.add_issue(crate::issue::Issue {
uuid, id: uuid,
crate::issue::Issue { author: String::from("sigil-03 <sigil@glyphs.tech>"),
author: String::from("sigil-03 <sigil@glyphs.tech>"), creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:38:40-06:00")
timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap()
.unwrap() .with_timezone(&chrono::Local),
.with_timezone(&chrono::Local), done_time: None,
tags: Vec::<String>::new(), tags: Vec::<String>::new(),
state: crate::issue::State::Done, state: crate::issue::State::Done,
dependencies: None, dependencies: None,
assignee: None, assignee: None,
description: String::from("oh yeah we got titles\n"), description: String::from("oh yeah we got titles\n"),
comments: Vec::<crate::comment::Comment>::new(), comments: Vec::<crate::comment::Comment>::new(),
dir, dir,
}, });
);
let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe");
let mut dir = std::path::PathBuf::from(issues_dir); let mut dir = std::path::PathBuf::from(issues_dir);
dir.push(&uuid); dir.push(&uuid);
expected.add_issue( expected.add_issue(
uuid,
crate::issue::Issue { crate::issue::Issue {
id: uuid,
author: String::from("sigil-03 <sigil@glyphs.tech>"), author: String::from("sigil-03 <sigil@glyphs.tech>"),
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() .unwrap()
.with_timezone(&chrono::Local), .with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::new(), tags: Vec::<String>::new(),
state: crate::issue::State::WontDo, state: crate::issue::State::WontDo,
dependencies: None, 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); let mut dir = std::path::PathBuf::from(issues_dir);
dir.push(&uuid); dir.push(&uuid);
expected.add_issue( expected.add_issue(
uuid,
crate::issue::Issue { crate::issue::Issue {
id: uuid,
author: String::from("sigil-03 <sigil@glyphs.tech>"), author: String::from("sigil-03 <sigil@glyphs.tech>"),
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() .unwrap()
.with_timezone(&chrono::Local), .with_timezone(&chrono::Local),
done_time: None,
tags: Vec::<String>::new(), tags: Vec::<String>::new(),
state: crate::issue::State::WontDo, state: crate::issue::State::WontDo,
dependencies: Some(vec![ dependencies: Some(vec![
crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac325"),
crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe"),
]), ]),
assignee: None, assignee: None,
description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"),

View file

@ -1,10 +1,12 @@
use std::str::FromStr; use std::str::FromStr;
pub mod comment; pub mod comment;
pub mod database;
pub mod git; pub mod git;
pub mod issue; pub mod issue;
pub mod issues; pub mod issues;
pub mod database;
use crate::issue::State;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ParseFilterError { pub enum ParseFilterError {
@ -12,6 +14,8 @@ pub enum ParseFilterError {
ParseError, ParseError,
#[error(transparent)] #[error(transparent)]
IssueParseError(#[from] crate::issue::IssueError), 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 // 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_assignees: std::collections::HashSet<&'a str>,
pub include_tags: std::collections::HashSet<&'a str>, pub include_tags: std::collections::HashSet<&'a str>,
pub exclude_tags: std::collections::HashSet<&'a str>, pub exclude_tags: std::collections::HashSet<&'a str>,
pub start_done_time: Option<chrono::DateTime<chrono::Local>>,
pub end_done_time: Option<chrono::DateTime<chrono::Local>>,
} }
impl<'a> Filter<'a> { impl<'a> Filter<'a> {
pub fn new_from_str(filter_str: &'a str) -> Result<Filter<'a>, ParseFilterError> { pub fn new() -> Filter<'a> {
use crate::issue::State; Self {
let mut f = Filter {
include_states: std::collections::HashSet::<crate::issue::State>::from([ include_states: std::collections::HashSet::<crate::issue::State>::from([
State::InProgress, State::InProgress,
State::Blocked, State::Blocked,
@ -38,51 +43,75 @@ impl<'a> Filter<'a> {
include_assignees: std::collections::HashSet::<&'a str>::new(), include_assignees: std::collections::HashSet::<&'a str>::new(),
include_tags: std::collections::HashSet::<&'a str>::new(), include_tags: std::collections::HashSet::<&'a str>::new(),
exclude_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(":") { pub fn parse(&mut self, filter_str: &'a str) -> Result<(), ParseFilterError> {
let tokens: Vec<&str> = filter_chunk_str.split("=").collect(); let tokens: Vec<&str> = filter_str.split("=").collect();
if tokens.len() != 2 { if tokens.len() != 2 {
return Err(ParseFilterError::ParseError); 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] { "assignee" => {
"state" => { self.include_assignees.clear();
f.include_states.clear(); for s in tokens[1].split(",") {
for s in tokens[1].split(",") { self.include_assignees.insert(s);
f.include_states.insert(crate::issue::State::from_str(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" => { "done-time" => {
f.include_assignees.clear(); self.start_done_time = None;
for s in tokens[1].split(",") { self.end_done_time = None;
f.include_assignees.insert(s); let times: Vec<&str> = tokens[1].split("..").collect();
} if times.len() > 2 {
}
"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);
return Err(ParseFilterError::ParseError); 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(())
} }
} }

View file

@ -0,0 +1 @@
2025-07-15T15:15:15-06:00

View file

@ -0,0 +1,3 @@
This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe
It has multiple lines

View file

@ -1,3 +0,0 @@
This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561
It has multiple lines

View file

@ -1,2 +0,0 @@
3fa5bfd93317ad25772680071d5ac3259cd2384f
dd79c8cfb8beeacd0460429944b4ecbe95a31561

4
tools/README.md Normal file
View file

@ -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.

11
tools/done-last-week Executable file
View file

@ -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}"

19
tools/set-done-time Executable file
View file

@ -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

43
tools/time-ent Executable file
View file

@ -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}"