ticket: update list query semantics

This commit is contained in:
2026-07-20 15:45:53 +09:00
parent edc495ac01
commit 0a5270a2cc
6 changed files with 324 additions and 100 deletions
+131 -26
View File
@@ -510,18 +510,75 @@ impl NewTicket {
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketFilter {
pub state: Option<TicketWorkflowState>,
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TicketStateSelector {
/// All non-closed workflow states: planning, ready, queued, inprogress, and done.
Active,
/// Every workflow state, including closed.
All,
/// An explicit set of workflow states.
States(BTreeSet<TicketWorkflowState>),
}
impl TicketFilter {
impl Default for TicketStateSelector {
fn default() -> Self {
Self::Active
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketListQuery {
pub state: TicketStateSelector,
}
impl Default for TicketListQuery {
fn default() -> Self {
Self::active()
}
}
impl TicketListQuery {
pub fn active() -> Self {
Self {
state: TicketStateSelector::Active,
}
}
pub fn all() -> Self {
Self { state: None }
Self {
state: TicketStateSelector::All,
}
}
pub fn state(state: TicketWorkflowState) -> Self {
Self { state: Some(state) }
Self::states([state])
}
pub fn states(states: impl IntoIterator<Item = TicketWorkflowState>) -> Self {
Self {
state: TicketStateSelector::States(states.into_iter().collect()),
}
}
pub fn matches_state(&self, state: TicketWorkflowState) -> bool {
match &self.state {
TicketStateSelector::Active => state != TicketWorkflowState::Closed,
TicketStateSelector::All => true,
TicketStateSelector::States(states) => states.contains(&state),
}
}
pub fn state_filter_label(&self) -> String {
match &self.state {
TicketStateSelector::Active => "active".to_string(),
TicketStateSelector::All => "all".to_string(),
TicketStateSelector::States(states) => states
.iter()
.map(|state| state.as_str())
.collect::<Vec<_>>()
.join(","),
}
}
}
@@ -873,7 +930,7 @@ impl TicketDoctorReport {
pub trait TicketBackend {
fn default_intake_ready_state_change_body(&self, from: &str) -> String;
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>>;
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>;
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
fn create(&self, input: NewTicket) -> Result<TicketRef>;
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>;
@@ -926,7 +983,7 @@ pub enum TicketBackendOperation {
from: String,
},
List {
filter: TicketFilter,
filter: TicketListQuery,
},
Show {
id: TicketIdOrSlug,
@@ -1143,10 +1200,10 @@ impl LocalTicketBackend {
}
}
pub fn list_partial(&self, filter: TicketFilter) -> Result<TicketPartialList> {
pub fn list_partial(&self, filter: TicketListQuery) -> Result<TicketPartialList> {
let mut output = TicketPartialList::default();
let mut invalid_seen = BTreeSet::new();
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
let item = dir.join("item.md");
if !item.exists() {
continue;
@@ -1155,10 +1212,7 @@ impl LocalTicketBackend {
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
{
Ok(meta) => {
if filter
.state
.is_some_and(|state| meta.workflow_state != state)
{
if !filter.matches_state(meta.workflow_state) {
continue;
}
output.tickets.push(ticket_summary_from_meta(meta));
@@ -1255,7 +1309,7 @@ impl LocalTicketBackend {
}
}
fn iter_ticket_dirs(&self, filter: TicketFilter) -> Result<Vec<PathBuf>> {
fn iter_ticket_dirs(&self, filter: TicketListQuery) -> Result<Vec<PathBuf>> {
let mut dirs = Vec::new();
if !self.root.exists() {
return Ok(dirs);
@@ -1275,10 +1329,10 @@ impl LocalTicketBackend {
if !item.is_file() {
continue;
}
if let Some(state) = filter.state {
if !matches!(filter.state, TicketStateSelector::All) {
let parsed = read_item_file(&item)?;
let meta = ticket_meta_for_dir(&path, parsed.frontmatter)?;
if meta.workflow_state != state {
if !filter.matches_state(meta.workflow_state) {
continue;
}
}
@@ -1495,7 +1549,7 @@ impl LocalTicketBackend {
fn all_ticket_relation_records(&self) -> Result<Vec<TicketRelation>> {
let mut relations = Vec::new();
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
relations.extend(self.read_ticket_relations_for_dir(&dir)?);
}
sort_ticket_relations(&mut relations);
@@ -1508,7 +1562,7 @@ impl LocalTicketBackend {
invalid_seen: &mut BTreeSet<String>,
) -> Result<Vec<TicketRelation>> {
let mut relations = Vec::new();
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
match self.read_ticket_relations_for_dir(&dir) {
Ok(records) => relations.extend(records),
Err(error) => {
@@ -1539,7 +1593,7 @@ impl LocalTicketBackend {
fn ticket_state_index(&self) -> Result<HashMap<String, TicketWorkflowState>> {
let mut states = HashMap::new();
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
let item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
states.insert(meta.id, meta.workflow_state);
@@ -1553,7 +1607,7 @@ impl LocalTicketBackend {
invalid_seen: &mut BTreeSet<String>,
) -> Result<HashMap<String, TicketWorkflowState>> {
let mut states = HashMap::new();
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
let item = dir.join("item.md");
match read_item_file(&item)
.and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter))
@@ -1589,7 +1643,7 @@ impl TicketBackend for LocalTicketBackend {
self.default_intake_ready_state_change_body(from)
}
fn list(&self, filter: TicketFilter) -> Result<Vec<TicketSummary>> {
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>> {
let mut tickets = Vec::new();
for dir in self.iter_ticket_dirs(filter)? {
let item = dir.join("item.md");
@@ -2088,7 +2142,7 @@ impl TicketBackend for LocalTicketBackend {
let dir = self.find_ticket_dir(&ticket)?;
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
} else {
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
}
}
@@ -2117,7 +2171,7 @@ impl TicketBackend for LocalTicketBackend {
}
}
for dir in self.iter_ticket_dirs(TicketFilter::all())? {
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
let ticket_id = match ticket_id_from_dir(&dir) {
Ok(id) => id,
Err(err) => {
@@ -3975,6 +4029,57 @@ state: planning
assert!(err.contains("invalid YAML frontmatter"), "{err}");
}
#[test]
fn list_query_defaults_to_active_and_supports_all_or_explicit_states() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap();
let mut ready_input = NewTicket::new("Ready Ticket");
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
let ready = backend.create(ready_input).unwrap();
let mut closed_input = NewTicket::new("Closed Ticket");
closed_input.workflow_state = Some(TicketWorkflowState::Closed);
let closed = backend.create(closed_input).unwrap();
let active = backend.list(TicketListQuery::default()).unwrap();
let active_ids = active
.iter()
.map(|ticket| ticket.id.as_str())
.collect::<Vec<_>>();
assert!(active_ids.contains(&planning.id.as_str()));
assert!(active_ids.contains(&ready.id.as_str()));
assert!(!active_ids.contains(&closed.id.as_str()));
let all = backend.list(TicketListQuery::all()).unwrap();
let all_ids = all
.iter()
.map(|ticket| ticket.id.as_str())
.collect::<Vec<_>>();
assert!(all_ids.contains(&planning.id.as_str()));
assert!(all_ids.contains(&ready.id.as_str()));
assert!(all_ids.contains(&closed.id.as_str()));
let ready_only = backend
.list(TicketListQuery::state(TicketWorkflowState::Ready))
.unwrap();
assert_eq!(ready_only.len(), 1);
assert_eq!(ready_only[0].id, ready.id);
let planning_or_closed = backend
.list(TicketListQuery::states([
TicketWorkflowState::Planning,
TicketWorkflowState::Closed,
]))
.unwrap();
let explicit_ids = planning_or_closed
.iter()
.map(|ticket| ticket.id.as_str())
.collect::<Vec<_>>();
assert!(explicit_ids.contains(&planning.id.as_str()));
assert!(explicit_ids.contains(&closed.id.as_str()));
assert!(!explicit_ids.contains(&ready.id.as_str()));
}
#[test]
fn create_writes_local_ticket_layout() {
let tmp = TempDir::new().unwrap();
@@ -4036,9 +4141,9 @@ state: planning
)
.unwrap();
assert!(backend.list(TicketFilter::all()).is_err());
assert!(backend.list(TicketListQuery::all()).is_err());
let partial = backend.list_partial(TicketFilter::all()).unwrap();
let partial = backend.list_partial(TicketListQuery::all()).unwrap();
assert_eq!(partial.tickets.len(), 1);
assert_eq!(partial.tickets[0].id, valid.id);
assert_eq!(partial.invalid_records.len(), 1);