issue/list: Port to new filter expressions

This commit is contained in:
Erik Kundt
2026-01-08 16:37:30 +01:00
parent 40e321db0e
commit bd36ed4cab
5 changed files with 418 additions and 351 deletions
-116
View File
@@ -1,129 +1,13 @@
use std::fmt;
use std::fmt::Write as _;
use anyhow::Result;
use radicle::cob::issue::{Issue, IssueId};
use radicle::issue::cache::Issues;
use radicle::issue::State;
use radicle::prelude::Did;
use radicle::storage::git::Repository;
use radicle::Profile;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Filter {
state: Option<State>,
assigned: bool,
assignees: Vec<Did>,
}
impl Default for Filter {
fn default() -> Self {
Self {
state: Some(State::default()),
assigned: false,
assignees: vec![],
}
}
}
impl Filter {
pub fn with_state(mut self, state: Option<State>) -> Self {
self.state = state;
self
}
pub fn with_assgined(mut self, assigned: bool) -> Self {
self.assigned = assigned;
self
}
pub fn with_assginee(mut self, assignee: Did) -> Self {
self.assignees.push(assignee);
self
}
}
impl fmt::Display for Filter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(state) = &self.state {
write!(f, "is:{state}")?;
f.write_char(' ')?;
}
if self.assigned {
f.write_str("is:assigned")?;
f.write_char(' ')?;
}
if !self.assignees.is_empty() {
f.write_str("assignees:")?;
f.write_char('[')?;
let mut assignees = self.assignees.iter().peekable();
while let Some(assignee) = assignees.next() {
f.write_str(&assignee.encode())?;
if assignees.peek().is_some() {
f.write_char(',')?;
}
}
f.write_char(']')?;
}
Ok(())
}
}
pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(IssueId, Issue)>> {
let cache = profile.issues(repository)?;
let issues = cache.list()?;
Ok(issues.flatten().collect())
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use anyhow::Result;
use radicle::issue;
use super::*;
#[test]
fn issue_filter_display_with_state_should_succeed() -> Result<()> {
let actual = Filter::default().with_state(Some(issue::State::Open));
assert_eq!(String::from("is:open "), actual.to_string());
Ok(())
}
#[test]
fn issue_filter_display_with_state_and_assigned_should_succeed() -> Result<()> {
let actual = Filter::default()
.with_state(Some(issue::State::Open))
.with_assgined(true);
assert_eq!(String::from("is:open is:assigned "), actual.to_string());
Ok(())
}
#[test]
fn issue_filter_display_with_status_and_author_should_succeed() -> Result<()> {
let actual = Filter::default()
.with_state(Some(issue::State::Open))
.with_assginee(Did::from_str(
"did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V",
)?);
assert_eq!(
String::from(
"is:open assignees:[did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V]"
),
actual.to_string()
);
Ok(())
}
}
+65 -4
View File
@@ -9,7 +9,8 @@ use lazy_static::lazy_static;
use radicle::cob::thread::CommentId;
use radicle::identity::RepoId;
use radicle::issue::IssueId;
use radicle::issue::{IssueId, State};
use radicle::prelude::Did;
use radicle::{issue, storage, Profile};
use radicle_cli as cli;
@@ -18,9 +19,10 @@ use cli::terminal::patch::Message;
use cli::terminal::Context;
use cli::terminal::{Args, Error, Help};
use crate::cob;
use crate::commands::tui_issue::list::IssueOperation;
use crate::terminal;
use crate::ui::items::filter::DidFilter;
use crate::ui::items::issue::filter::IssueFilter;
use crate::ui::TerminalInfo;
lazy_static! {
@@ -68,9 +70,67 @@ pub enum OperationName {
Unknown,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ListFilter {
state: Option<State>,
assigned: bool,
assignees: Vec<Did>,
}
impl Default for ListFilter {
fn default() -> Self {
Self {
state: Some(State::default()),
assigned: false,
assignees: vec![],
}
}
}
impl ListFilter {
pub fn with_state(mut self, state: Option<State>) -> Self {
self.state = state;
self
}
pub fn with_assgined(mut self, assigned: bool) -> Self {
self.assigned = assigned;
self
}
pub fn with_assginee(mut self, assignee: Did) -> Self {
self.assignees.push(assignee);
self
}
}
#[allow(clippy::from_over_into)]
impl Into<IssueFilter> for (Did, ListFilter) {
fn into(self) -> IssueFilter {
let (me, mut filter) = self;
let mut and = filter
.state
.map(|s| vec![IssueFilter::State(s)])
.unwrap_or(vec![]);
let mut assignees = filter.assigned.then_some(vec![me]).unwrap_or_default();
assignees.append(&mut filter.assignees);
if assignees.len() == 1 {
and.push(IssueFilter::Assignee(DidFilter::Single(
*assignees.first().unwrap(),
)));
} else if assignees.len() > 1 {
and.push(IssueFilter::Assignee(DidFilter::Or(assignees)));
}
IssueFilter::And(and)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ListOptions {
filter: cob::issue::Filter,
filter: ListFilter,
json: bool,
}
@@ -205,13 +265,14 @@ pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
loop {
let profile = ctx.profile()?;
let me = profile.did();
let rid = options.repo.unwrap_or(rid);
let repository = profile.storage.repository(rid)?;
let context = list::Context {
profile,
repository,
filter: opts.filter.clone(),
filter: (me, opts.filter.clone()).into(),
search: state.search.clone(),
issue: state.issue_id,
comment: state.comment_id,
+57 -30
View File
@@ -33,7 +33,8 @@ use tui::{Channel, Exit};
use crate::cob::issue;
use crate::settings::{self, ThemeBundle, ThemeMode};
use crate::ui::items::filter::Filter;
use crate::ui::items::issue::{Issue, IssueFilter};
use crate::ui::items::issue::filter::IssueFilter;
use crate::ui::items::issue::Issue;
use crate::ui::items::HasId;
use crate::ui::{format, TerminalInfo};
@@ -98,7 +99,7 @@ const HELP: &str = r#"# Generic keybindings
pub struct Context {
pub profile: Profile,
pub repository: Repository,
pub filter: issue::Filter,
pub filter: IssueFilter,
pub search: Option<String>,
pub issue: Option<IssueId>,
pub comment: Option<CommentId>,
@@ -478,7 +479,6 @@ impl store::Update<Message> for App {
None
}
Change::Comment { state } => {
log::info!("Change::Comments: {state:?}");
if let Some(item) = &self.state.preview.issue {
self.state.preview.selected_comments.insert(
item.id,
@@ -781,23 +781,21 @@ impl App {
let closed = solved + other;
let filtered_counts = format!(" {}/{} ", filtered.len(), issues.len());
let mut columns = vec![
Column::new(
Span::raw(" Issue ".to_string()).cyan().dim().reversed(),
Constraint::Length(7),
),
Column::new(
Span::raw(format!(" {search} "))
.into_left_aligned_line()
.style(ui.theme().bar_on_black_style)
.cyan()
.dim(),
Constraint::Fill(1),
),
];
if filter.state().is_none() {
columns.extend_from_slice(&[
if !self.state.filter.has_state() {
[
Column::new(
Span::raw(" Search ".to_string()).cyan().dim().reversed(),
Constraint::Length(8),
),
Column::new(
Span::raw(format!(" {search} "))
.into_left_aligned_line()
.style(ui.theme().bar_on_black_style)
.cyan()
.dim(),
Constraint::Fill(1),
),
Column::new(
Span::raw(" ● ")
.into_right_aligned_line()
@@ -812,6 +810,12 @@ impl App {
.into_right_aligned_line(),
Constraint::Length(open.to_string().chars().count() as u16),
),
Column::new(
Span::from(" ")
.style(ui.theme().bar_on_black_style)
.into_right_aligned_line(),
Constraint::Length(1),
),
Column::new(
Span::raw(" ● ")
.style(ui.theme().bar_on_black_style)
@@ -832,18 +836,41 @@ impl App {
.into_right_aligned_line(),
Constraint::Length(1),
),
]);
Column::new(
Span::raw(filtered_counts.clone())
.into_right_aligned_line()
.cyan()
.dim()
.reversed(),
Constraint::Length(filtered_counts.chars().count() as u16),
),
]
.to_vec()
} else {
[
Column::new(
Span::raw(" Search ".to_string()).cyan().dim().reversed(),
Constraint::Length(8),
),
Column::new(
Span::raw(format!(" {search} "))
.into_left_aligned_line()
.style(ui.theme().bar_on_black_style)
.cyan()
.dim(),
Constraint::Fill(1),
),
Column::new(
Span::raw(filtered_counts.clone())
.into_right_aligned_line()
.cyan()
.dim()
.reversed(),
Constraint::Length(filtered_counts.chars().count() as u16),
),
]
.to_vec()
}
columns.extend_from_slice(&[Column::new(
Span::raw(filtered_counts.clone())
.into_right_aligned_line()
.cyan()
.dim()
.reversed(),
Constraint::Length(filtered_counts.chars().count() as u16),
)]);
columns
};
ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None));
-36
View File
@@ -250,39 +250,3 @@ impl ToTree<String> for CommentItem {
vec![item]
}
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use std::str::FromStr;
use radicle::issue::State;
use crate::ui::items::issue::IssueFilter;
use super::*;
#[test]
fn issue_item_filter_from_str_should_succeed() -> Result<()> {
let search = r#"is:open is:assigned assignees:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
let actual = IssueFilter::from_str(search)?;
let expected = IssueFilter {
state: Some(State::Open),
authors: vec![Did::from_str(
"did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx",
)?],
authored: true,
assigned: true,
assignees: vec![
Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
],
search: Some("cli".to_string()),
};
assert_eq!(expected, actual);
Ok(())
}
}
+296 -165
View File
@@ -1,15 +1,9 @@
use std::fmt::Debug;
use std::str::FromStr;
use nom::bytes::complete::{tag, take};
use nom::multi::separated_list0;
use nom::sequence::{delimited, preceded};
use nom::{IResult, Parser};
use radicle::cob::thread::CommentId;
use radicle::cob::{Label, ObjectId, Timestamp};
use radicle::issue::{CloseReason, IssueId};
use radicle::prelude::Did;
use radicle::issue::IssueId;
use radicle::Profile;
use ratatui::style::{Style, Stylize};
@@ -21,7 +15,6 @@ use tui::ui::span;
use tui::ui::ToRow;
use crate::ui::format;
use crate::ui::items::filter::Filter;
use crate::ui::items::{AuthorItem, CommentItem, HasId};
#[derive(Clone, Debug)]
@@ -149,168 +142,306 @@ impl HasId for Issue {
}
}
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub(crate) struct IssueFilter {
pub(crate) state: Option<radicle::issue::State>,
pub(crate) authored: bool,
pub(crate) authors: Vec<Did>,
pub(crate) assigned: bool,
pub(crate) assignees: Vec<Did>,
pub(crate) search: Option<String>,
}
pub mod filter {
use std::fmt;
use std::fmt::Debug;
use std::fmt::Write as _;
use std::str::FromStr;
impl IssueFilter {
pub fn state(&self) -> Option<radicle::issue::State> {
self.state
use nom::branch::alt;
use nom::bytes::complete::{tag_no_case, take_while1};
use nom::character::complete::multispace0;
use nom::combinator::{map, value};
use nom::multi::many0;
use nom::sequence::preceded;
use nom::IResult;
use radicle::issue::CloseReason;
use radicle::issue::State;
use crate::ui::items::filter;
use crate::ui::items::filter::DidFilter;
use crate::ui::items::filter::Filter;
use super::Issue;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IssueFilter {
State(State),
Author(DidFilter),
Assignee(DidFilter),
Search(String),
And(Vec<IssueFilter>),
Empty,
Invalid,
}
}
impl Filter<Issue> for IssueFilter {
fn matches(&self, issue: &Issue) -> bool {
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use radicle::issue::State;
let matcher = SkimMatcherV2::default();
let matches_state = match self.state {
Some(State::Closed {
reason: CloseReason::Other,
}) => matches!(issue.state, State::Closed { .. }),
Some(state) => issue.state == state,
None => true,
};
let matches_authored = if self.authored {
issue.author.you
} else {
true
};
let matches_authors = if !self.authors.is_empty() {
{
self.authors
.iter()
.any(|other| issue.author.nid == Some(**other))
}
} else {
true
};
let matches_assigned = if self.assigned {
issue.assignees.iter().any(|assignee| assignee.you)
} else {
true
};
let matches_assignees = if !self.assignees.is_empty() {
{
self.assignees.iter().any(|other| {
issue
.assignees
.iter()
.filter_map(|author| author.nid)
.collect::<Vec<_>>()
.contains(other)
})
}
} else {
true
};
let matches_search = match &self.search {
Some(search) => match matcher.fuzzy_match(&issue.title, search) {
Some(score) => score == 0 || score > 60,
_ => false,
},
None => true,
};
matches_state
&& matches_authored
&& matches_authors
&& matches_assigned
&& matches_assignees
&& matches_search
impl Default for IssueFilter {
fn default() -> Self {
IssueFilter::State(State::Open)
}
}
}
impl FromStr for IssueFilter {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
use radicle::issue::State;
let mut state = None;
let mut search = String::new();
let mut authored = false;
let mut authors = vec![];
let mut assigned = false;
let mut assignees = vec![];
let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
preceded(
tag("authors:"),
delimited(
tag("["),
separated_list0(tag(","), take(56_usize)),
tag("]"),
),
)
.parse(input)
};
let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
preceded(
tag("assignees:"),
delimited(
tag("["),
separated_list0(tag(","), take(56_usize)),
tag("]"),
),
)
.parse(input)
};
let parts = value.split(' ');
for part in parts {
match part {
"is:open" => state = Some(State::Open),
"is:closed" => {
state = Some(State::Closed {
reason: CloseReason::Other,
})
}
"is:solved" => {
state = Some(State::Closed {
reason: CloseReason::Solved,
})
}
"is:authored" => authored = true,
"is:assigned" => assigned = true,
other => {
if let Ok((_, dids)) = assignees_parser.parse(other) {
for did in dids {
assignees.push(Did::from_str(did)?);
}
} else if let Ok((_, dids)) = authors_parser.parse(other) {
for did in dids {
authors.push(Did::from_str(did)?);
}
} else {
search.push_str(other);
}
}
}
impl IssueFilter {
pub fn is_default(&self) -> bool {
*self == IssueFilter::default()
}
Ok(Self {
state,
authored,
authors,
assigned,
assignees,
search: Some(search),
})
pub fn has_state(&self) -> bool {
match self {
IssueFilter::State(_) => true,
IssueFilter::And(filters) => {
filters.iter().any(|f| matches!(f, IssueFilter::State(_)))
}
_ => false,
}
}
}
impl fmt::Display for IssueFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IssueFilter::State(state) => {
let state = match state {
State::Open => "open",
State::Closed { reason } => match reason {
CloseReason::Solved => "solved",
CloseReason::Other => "closed",
},
};
write!(f, "state={state}")?;
f.write_char(' ')?;
}
IssueFilter::Author(filter) => {
write!(f, "author={filter}")?;
f.write_char(' ')?;
}
IssueFilter::Assignee(filter) => {
write!(f, "assignee={filter}")?;
f.write_char(' ')?;
}
IssueFilter::Search(search) => {
write!(f, "{search}")?;
f.write_char(' ')?;
}
IssueFilter::And(filters) => {
let mut it = filters.iter().peekable();
while let Some(filter) = it.next() {
write!(f, "{filter}")?;
if it.peek().is_none() {
f.write_char(' ')?;
}
}
}
IssueFilter::Empty | IssueFilter::Invalid => {}
}
Ok(())
}
}
impl Filter<Issue> for IssueFilter {
fn matches(&self, issue: &Issue) -> bool {
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
let matcher = SkimMatcherV2::default();
match self {
IssueFilter::State(state) => issue.state == *state,
IssueFilter::Author(author_filter) => match author_filter {
DidFilter::Single(author) => issue.author.nid == Some(**author),
DidFilter::Or(authors) => authors
.iter()
.any(|other| issue.author.nid == Some(**other)),
},
IssueFilter::Assignee(assignee_filter) => match assignee_filter {
DidFilter::Single(assignee) => issue
.assignees
.iter()
.any(|other| other.nid == Some(**assignee)),
DidFilter::Or(assignees) => issue.assignees.iter().any(|other| {
assignees
.iter()
.any(|assignee| other.nid == Some(**assignee))
}),
},
IssueFilter::Search(search) => {
match matcher.fuzzy_match(
&format!(
"{} {} {}",
&issue.id.to_string(),
&issue.title,
&issue
.author
.alias
.as_ref()
.map(|a| a.to_string())
.unwrap_or_default()
),
search,
) {
Some(score) => score == 0 || score > filter::FUZZY_MIN_SCORE,
_ => false,
}
}
IssueFilter::And(filters) => filters.iter().all(|f| f.matches(issue)),
IssueFilter::Empty => true,
IssueFilter::Invalid => false,
}
}
}
impl FromStr for IssueFilter {
type Err = anyhow::Error;
fn from_str(filter_exp: &str) -> Result<Self, Self::Err> {
use nom::Parser;
fn parse_state(input: &str) -> IResult<&str, State> {
alt((
value(State::Open, tag_no_case("open")),
value(
State::Closed {
reason: radicle::issue::CloseReason::Other,
},
tag_no_case("closed"),
),
value(
State::Closed {
reason: radicle::issue::CloseReason::Solved,
},
tag_no_case("solved"),
),
))
.parse(input)
}
fn parse_state_filter(input: &str) -> IResult<&str, IssueFilter> {
map(
preceded(
(
tag_no_case("state"),
multispace0,
tag_no_case("="),
multispace0,
),
parse_state,
),
IssueFilter::State,
)
.parse(input)
}
fn parse_assignee_filter(input: &str) -> IResult<&str, IssueFilter> {
map(
preceded(
(
tag_no_case("assignee"),
multispace0,
tag_no_case("="),
multispace0,
),
alt((filter::parse_did_single, filter::parse_did_or)),
),
IssueFilter::Assignee,
)
.parse(input)
}
fn parse_author_filter(input: &str) -> IResult<&str, IssueFilter> {
map(
preceded(
(
tag_no_case("author"),
multispace0,
tag_no_case("="),
multispace0,
),
alt((filter::parse_did_single, filter::parse_did_or)),
),
IssueFilter::Author,
)
.parse(input)
}
fn parse_search_filter(input: &str) -> IResult<&str, IssueFilter> {
map(
take_while1(|c: char| c.is_alphanumeric() || c == '_' || c == '-'),
|s: &str| IssueFilter::Search(s.to_string()),
)
.parse(input)
}
fn parse_single_filter(input: &str) -> IResult<&str, IssueFilter> {
alt((
parse_state_filter,
parse_assignee_filter,
parse_author_filter,
parse_search_filter,
))
.parse(input)
}
fn parse_filters(input: &str) -> IResult<&str, Vec<IssueFilter>> {
many0(preceded(multispace0, parse_single_filter)).parse(input)
}
let parse_filter_expression = |input: &str| -> Result<IssueFilter, String> {
match parse_filters(input) {
Ok((remaining, filters)) => {
let remaining = remaining.trim();
if !remaining.is_empty() {
return Err(format!("Unparsed input remaining: '{remaining}'"));
}
if filters.is_empty() {
return Ok(IssueFilter::Empty);
}
if filters.len() == 1 {
Ok(filters.into_iter().next().unwrap())
} else {
Ok(IssueFilter::And(filters))
}
}
Err(e) => Err(format!("Parse error: {e}")),
}
};
parse_filter_expression(filter_exp).map_err(|err| anyhow::format_err!(err))
}
}
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use std::str::FromStr;
use radicle::{issue::State, prelude::Did};
use crate::ui::items::{filter::DidFilter, issue::filter::IssueFilter};
#[test]
fn issue_item_filter_from_str_should_succeed() -> Result<()> {
let search = r#"state=open assignee=(did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB or did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx) author=did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx cli"#;
let actual = IssueFilter::from_str(search)?;
let expected = IssueFilter::And(vec![
IssueFilter::State(State::Open),
IssueFilter::Assignee(DidFilter::Or(vec![
Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
])),
IssueFilter::Author(DidFilter::Single(Did::from_str(
"did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx",
)?)),
IssueFilter::Search("cli".to_string()),
]);
assert_eq!(expected, actual);
Ok(())
}
}