add ent list filter by assignee

I'm not sure about the filter format...

There are two independent filters: "state" and "assignee".  The "state"
filter defaults to including issues whose state is InProgress, Blocked,
Backlog, or New.  The "assignee" filter defaults to including all issues,
assigned or not.

The two filters can be independently overridden by the `ent list
FILTER` command.  FILTER is a string containing chunks separated by
":", like the PATH environment variable.  Each chunk is of the form
"name=value[,value...]".  "name" can be either "state" or "assignee".

The "value" arguments to the "state" filter must be one of the valid
states, or it's a parse error.

The "value" arguments to the "assignee" filter are used to
string-compare against the issues "assignee" field, exact matches are
accepted and everything else is rejected.  A special assignee filter of
the empty string matches issues that don't have an assignee.

Some examples:

* `ent list` shows issues in the states listed above, and don't filter
  based on assignee at all.

* `ent list assignee=seb` shows issues in the states listed above,
  but only if the assignee is "seb".

* `ent list assignee=seb,` shows issues in the states listed above,
  but only if the assignee is "seb" or if there is no assignee.

* `ent list state=done` shows all issues in the Done state.

* `ent list state=done:assignee=seb` shows issues in the Done state that
  are assigned to "seb".
This commit is contained in:
Sebastian Kuzminsky 2025-07-08 22:21:09 -06:00
parent 86a22f88f3
commit df7b5c6aa4
2 changed files with 59 additions and 21 deletions

View file

@ -71,19 +71,31 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<(
Commands::List { filter } => { Commands::List { filter } => {
let issues = let issues =
entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?;
let filter = entomologist::parse_filter(filter)?; let filter = entomologist::Filter::new_from_str(filter)?;
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>,
>::new(); >::new();
for (uuid, issue) in issues.issues.iter() { for (uuid, issue) in issues.issues.iter() {
if filter.include_states.contains(&issue.state) { 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;
}
}
// This issue passed all the filters, include it in list.
uuids_by_state uuids_by_state
.entry(issue.state.clone()) .entry(issue.state.clone())
.or_default() .or_default()
.push(uuid); .push(uuid);
} }
}
use entomologist::issue::State; use entomologist::issue::State;
for state in [ for state in [

View file

@ -17,24 +17,50 @@ pub enum ParseFilterError {
// i'm starting with obvious easy things. Chumsky looks appealing but // i'm starting with obvious easy things. Chumsky looks appealing but
// more research is needed. // more research is needed.
#[derive(Debug)] #[derive(Debug)]
pub struct Filter { pub struct Filter<'a> {
pub include_states: std::collections::HashSet<crate::issue::State>, pub include_states: std::collections::HashSet<crate::issue::State>,
pub include_assignees: std::collections::HashSet<&'a str>,
} }
// Parses a filter description matching "state=STATE[,STATE*]" impl<'a> Filter<'a> {
pub fn parse_filter(filter_str: &str) -> Result<Filter, ParseFilterError> { pub fn new_from_str(filter_str: &'a str) -> Result<Filter<'a>, ParseFilterError> {
let tokens: Vec<&str> = filter_str.split("=").collect(); use crate::issue::State;
let mut f = Filter {
include_states: std::collections::HashSet::<crate::issue::State>::from([
State::InProgress,
State::Blocked,
State::Backlog,
State::New,
]),
include_assignees: std::collections::HashSet::<&'a str>::new(),
};
for filter_chunk_str in filter_str.split(":") {
let tokens: Vec<&str> = filter_chunk_str.split("=").collect();
if tokens.len() != 2 { if tokens.len() != 2 {
return Err(ParseFilterError::ParseError); return Err(ParseFilterError::ParseError);
} }
if tokens[0] != "state" {
match tokens[0] {
"state" => {
f.include_states.clear();
for s in tokens[1].split(",") {
f.include_states.insert(crate::issue::State::from_str(s)?);
}
}
"assignee" => {
f.include_assignees.clear();
for s in tokens[1].split(",") {
f.include_assignees.insert(s);
}
}
_ => {
println!("unknown filter chunk '{}'", filter_chunk_str);
return Err(ParseFilterError::ParseError); return Err(ParseFilterError::ParseError);
} }
}
let mut include_states = std::collections::HashSet::<crate::issue::State>::new();
for s in tokens[1].split(",") {
include_states.insert(crate::issue::State::from_str(s)?);
} }
Ok(Filter { include_states }) Ok(f)
}
} }