mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-10-18 19:10:36 +08:00
issue/list: Port to new filter expressions
This commit is contained in:
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user