ticket: update list query semantics
This commit is contained in:
parent
edc495ac01
commit
0a5270a2cc
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -97,8 +97,8 @@ const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed T
|
|||
Inputs mirror the Ticket `item.md` fields; `title` is required, `body` is Markdown, and the \
|
||||
backend assigns the id and writes the local Ticket file layout under the configured backend root.";
|
||||
const LIST_DESCRIPTION: &str = "List Tickets from the configured typed Ticket backend as a \
|
||||
lightweight bounded overview for selection only. Filter by state (`planning`, `ready`, `queued`, \
|
||||
`inprogress`, `done`, `closed`, or `all`). Output is short summaries only; use TicketShow before \
|
||||
lightweight bounded overview for selection only. Filter by query (`active`, `all`, a single workflow \
|
||||
state, or an explicit workflow-state list). Output is short summaries only; use TicketShow before \
|
||||
routing, closing, planning, or implementation decisions.";
|
||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
|
||||
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
|
||||
|
|
@ -214,7 +214,7 @@ impl TicketBackend for TicketToolBackend {
|
|||
self.backend.default_intake_ready_state_change_body(from)
|
||||
}
|
||||
|
||||
fn list(&self, filter: crate::TicketFilter) -> TicketResult<Vec<TicketSummary>> {
|
||||
fn list(&self, filter: crate::TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
|
||||
self.backend.list(filter)
|
||||
}
|
||||
|
||||
|
|
@ -375,9 +375,10 @@ impl TicketWorkflowStateParam {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketListStateParam {
|
||||
Active,
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
|
|
@ -388,47 +389,62 @@ enum TicketListStateParam {
|
|||
}
|
||||
|
||||
impl TicketListStateParam {
|
||||
fn as_filter(self) -> (crate::TicketFilter, &'static str) {
|
||||
fn as_state(self) -> Option<TicketWorkflowState> {
|
||||
match self {
|
||||
Self::Planning => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Planning),
|
||||
"planning",
|
||||
),
|
||||
Self::Ready => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Ready),
|
||||
"ready",
|
||||
),
|
||||
Self::Queued => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Queued),
|
||||
"queued",
|
||||
),
|
||||
Self::Inprogress => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::InProgress),
|
||||
"inprogress",
|
||||
),
|
||||
Self::Done => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Done),
|
||||
"done",
|
||||
),
|
||||
Self::Closed => (
|
||||
crate::TicketFilter::state(TicketWorkflowState::Closed),
|
||||
"closed",
|
||||
),
|
||||
Self::All => (crate::TicketFilter::all(), "all"),
|
||||
Self::Planning => Some(TicketWorkflowState::Planning),
|
||||
Self::Ready => Some(TicketWorkflowState::Ready),
|
||||
Self::Queued => Some(TicketWorkflowState::Queued),
|
||||
Self::Inprogress => Some(TicketWorkflowState::InProgress),
|
||||
Self::Done => Some(TicketWorkflowState::Done),
|
||||
Self::Closed => Some(TicketWorkflowState::Closed),
|
||||
Self::Active | Self::All => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketListParams {
|
||||
/// State filter. Defaults to all Tickets.
|
||||
/// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets.
|
||||
#[serde(default)]
|
||||
state: Option<TicketListStateParam>,
|
||||
/// Explicit workflow-state filter list. Cannot be combined with `state`.
|
||||
#[serde(default)]
|
||||
states: Option<Vec<TicketWorkflowStateParam>>,
|
||||
/// Maximum number of summaries to return. Defaults to 50, max 100.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl TicketListParams {
|
||||
fn into_query(self) -> Result<(crate::TicketListQuery, String, Option<usize>), TicketError> {
|
||||
let query = if let Some(states) = self.states {
|
||||
if self.state.is_some() {
|
||||
return Err(TicketError::Conflict(
|
||||
"TicketList accepts either `state` or `states`, not both".to_string(),
|
||||
));
|
||||
}
|
||||
if states.is_empty() {
|
||||
return Err(TicketError::Conflict(
|
||||
"TicketList `states` must include at least one workflow state".to_string(),
|
||||
));
|
||||
}
|
||||
crate::TicketListQuery::states(states.into_iter().map(|state| state.into_state()))
|
||||
} else {
|
||||
match self.state.unwrap_or(TicketListStateParam::Active) {
|
||||
TicketListStateParam::Active => crate::TicketListQuery::active(),
|
||||
TicketListStateParam::All => crate::TicketListQuery::all(),
|
||||
state => crate::TicketListQuery::state(
|
||||
state
|
||||
.as_state()
|
||||
.expect("workflow state list param maps to TicketWorkflowState"),
|
||||
),
|
||||
}
|
||||
};
|
||||
let label = query.state_filter_label();
|
||||
Ok((query, label, self.limit))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketShowParams {
|
||||
/// Ticket id. Exactly one of `id` or `query` must be provided.
|
||||
|
|
@ -825,9 +841,10 @@ impl Tool for TicketListTool {
|
|||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketListParams = parse_input("TicketList", input_json)?;
|
||||
let state = params.state.unwrap_or(TicketListStateParam::All);
|
||||
let (filter, state_filter) = state.as_filter();
|
||||
let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||
let (filter, state_filter, params_limit) = params
|
||||
.into_query()
|
||||
.map_err(|error| backend_error("TicketList", error))?;
|
||||
let limit = bounded(params_limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|
||||
let tickets = self
|
||||
.backend
|
||||
.list(filter)
|
||||
|
|
@ -1825,6 +1842,16 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
let active = list
|
||||
.execute(&json!({}).to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let active_json: Value = serde_json::from_str(&active.content.unwrap()).unwrap();
|
||||
assert_eq!(active_json["state_filter"], "active");
|
||||
assert_eq!(active_json["count"].as_u64(), Some(3));
|
||||
assert_eq!(active_json["returned"].as_u64(), Some(3));
|
||||
assert_eq!(active_json["truncated"].as_bool(), Some(false));
|
||||
|
||||
let all = list
|
||||
.execute(&json!({ "state": "all" }).to_string(), Default::default())
|
||||
.await
|
||||
|
|
@ -1857,6 +1884,53 @@ mod tests {
|
|||
assert_eq!(closed_json["truncated"].as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_list_tool_accepts_multi_state_list_and_rejects_mixed_filters() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let list = tool_by_name(backend.clone(), "TicketList");
|
||||
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 listed = list
|
||||
.execute(
|
||||
&json!({ "states": ["planning", "closed"] }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap();
|
||||
assert_eq!(listed_json["state_filter"], "planning,closed");
|
||||
assert_eq!(listed_json["count"].as_u64(), Some(2));
|
||||
let listed_ids = listed_json["tickets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|ticket| ticket["id"].as_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(listed_ids.contains(&planning.id.as_str()));
|
||||
assert!(listed_ids.contains(&closed.id.as_str()));
|
||||
assert!(!listed_ids.contains(&ready.id.as_str()));
|
||||
|
||||
let mixed = list
|
||||
.execute(
|
||||
&json!({ "state": "active", "states": ["planning"] }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(mixed.is_err());
|
||||
|
||||
let empty = list
|
||||
.execute(&json!({ "states": [] }).to_string(), Default::default())
|
||||
.await;
|
||||
assert!(empty.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
|
@ -2408,7 +2482,10 @@ mod tests {
|
|||
assert!(!id.contains("escape"));
|
||||
assert!(!temp.path().join("escape").exists());
|
||||
assert!(temp.path().join("tickets").join(id).is_dir());
|
||||
assert_eq!(backend.list(crate::TicketFilter::all()).unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
backend.list(crate::TicketListQuery::all()).unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ use ticket::config::{
|
|||
WORKSPACE_SETTINGS_RELATIVE_PATH,
|
||||
};
|
||||
use ticket::{
|
||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
||||
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug,
|
||||
TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary,
|
||||
TicketWorkflowState,
|
||||
};
|
||||
|
||||
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
|
||||
|
|
@ -693,7 +694,7 @@ fn load_orchestration_ticket_overlay_states(
|
|||
let backend = LocalTicketBackend::new(ticket_root.to_path_buf())
|
||||
.with_record_language(overlay_config.ticket_record_language());
|
||||
let partial = backend
|
||||
.list_partial(TicketFilter::all())
|
||||
.list_partial(TicketListQuery::all())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut states = BTreeMap::new();
|
||||
for summary in partial.tickets {
|
||||
|
|
@ -1092,7 +1093,7 @@ fn build_ticket_rows(
|
|||
registry: &PanelRegistrySnapshot,
|
||||
orchestration_overlay: &BTreeMap<String, TicketStateOverlay>,
|
||||
) -> ticket::Result<TicketRowsBuild> {
|
||||
let partial = backend.list_partial(TicketFilter::all())?;
|
||||
let partial = backend.list_partial(TicketListQuery::all())?;
|
||||
let mut ticket_rows = Vec::new();
|
||||
let mut invalid_records = partial.invalid_records;
|
||||
for summary in partial.tickets {
|
||||
|
|
@ -2522,7 +2523,7 @@ mod tests {
|
|||
input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
});
|
||||
let ticket_id = backend
|
||||
.list(TicketFilter::all())
|
||||
.list(TicketListQuery::all())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|ticket| ticket.title == "Ticket With Intake")
|
||||
|
|
@ -2619,7 +2620,7 @@ mod tests {
|
|||
write_ticket_config(temp.path());
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
create_ticket(&backend, "Claimed Planning", |_| {});
|
||||
let summary = backend.list(TicketFilter::all()).unwrap().remove(0);
|
||||
let summary = backend.list(TicketListQuery::all()).unwrap().remove(0);
|
||||
let store = PanelRegistryStore::from_root(temp.path().join("local-registry"));
|
||||
store
|
||||
.claim_ticket(&summary.id, None, "ticket-claimed-intake", "intake")
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ use ticket::{
|
|||
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
||||
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
||||
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
|
||||
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketFilter, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketRef, TicketRelation, TicketRelationKind, TicketRelationView,
|
||||
TicketReview, TicketStateChange, TicketSummary,
|
||||
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind,
|
||||
TicketRelationView, TicketReview, TicketStateChange, TicketSummary,
|
||||
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
||||
tool::{
|
||||
TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES,
|
||||
|
|
@ -400,7 +400,7 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||
}
|
||||
}
|
||||
|
||||
fn list(&self, filter: TicketFilter) -> TicketResult<Vec<TicketSummary>> {
|
||||
fn list(&self, filter: TicketListQuery) -> TicketResult<Vec<TicketSummary>> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::List { filter }),
|
||||
TicketBackendOperationResult::Tickets
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
|||
use project_record::validate_record_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ticket::config::TicketConfig;
|
||||
use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug};
|
||||
use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ impl LocalProjectRecordReader {
|
|||
}
|
||||
|
||||
pub fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
|
||||
let partial = self.ticket_backend.list_partial(TicketFilter::all())?;
|
||||
let partial = self.ticket_backend.list_partial(TicketListQuery::all())?;
|
||||
let mut items = partial
|
||||
.tickets
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use ticket::config::{
|
|||
};
|
||||
use ticket::{
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend,
|
||||
TicketDoctorSeverity, TicketEventKind, TicketFilter, TicketIdOrSlug, TicketIntakeSummary,
|
||||
TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||
TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, TicketWorkflowState,
|
||||
};
|
||||
|
||||
|
|
@ -45,15 +45,11 @@ pub struct CreateOptions {
|
|||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ListState {
|
||||
Planning,
|
||||
Ready,
|
||||
Queued,
|
||||
InProgress,
|
||||
Done,
|
||||
Closed,
|
||||
Active,
|
||||
All,
|
||||
States(Vec<TicketWorkflowState>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -381,13 +377,9 @@ fn list(
|
|||
options: ListOptions,
|
||||
) -> Result<TicketCliOutput, TicketCliError> {
|
||||
let filter = match options.state {
|
||||
ListState::Planning => TicketFilter::state(TicketWorkflowState::Planning),
|
||||
ListState::Ready => TicketFilter::state(TicketWorkflowState::Ready),
|
||||
ListState::Queued => TicketFilter::state(TicketWorkflowState::Queued),
|
||||
ListState::InProgress => TicketFilter::state(TicketWorkflowState::InProgress),
|
||||
ListState::Done => TicketFilter::state(TicketWorkflowState::Done),
|
||||
ListState::Closed => TicketFilter::state(TicketWorkflowState::Closed),
|
||||
ListState::All => TicketFilter::all(),
|
||||
ListState::Active => TicketListQuery::active(),
|
||||
ListState::All => TicketListQuery::all(),
|
||||
ListState::States(states) => TicketListQuery::states(states),
|
||||
};
|
||||
let tickets = backend.list(filter)?;
|
||||
let count = tickets.len();
|
||||
|
|
@ -750,7 +742,7 @@ fn parse_create(args: &[String]) -> Result<CreateOptions, TicketCliError> {
|
|||
}
|
||||
|
||||
fn parse_list(args: &[String]) -> Result<ListOptions, TicketCliError> {
|
||||
let mut state = ListState::All;
|
||||
let mut state = ListState::Active;
|
||||
let mut limit = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
|
|
@ -1042,17 +1034,42 @@ fn option_with_value(
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_list_state(value: &str) -> Result<ListState, TicketCliError> {
|
||||
match value {
|
||||
"planning" => Ok(ListState::Planning),
|
||||
"ready" => Ok(ListState::Ready),
|
||||
"queued" => Ok(ListState::Queued),
|
||||
"inprogress" => Ok(ListState::InProgress),
|
||||
"done" => Ok(ListState::Done),
|
||||
"closed" => Ok(ListState::Closed),
|
||||
"all" => Ok(ListState::All),
|
||||
_ => Err(TicketCliError::new(format!("invalid state: {value}"))),
|
||||
fn parse_list_state(raw: &str) -> Result<ListState, TicketCliError> {
|
||||
let tokens = raw
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if tokens.is_empty() {
|
||||
return Err(TicketCliError::new("--state must not be empty"));
|
||||
}
|
||||
if tokens.len() == 1 {
|
||||
match tokens[0] {
|
||||
"active" => return Ok(ListState::Active),
|
||||
"all" => return Ok(ListState::All),
|
||||
_ => {}
|
||||
}
|
||||
} else if tokens
|
||||
.iter()
|
||||
.any(|token| *token == "active" || *token == "all")
|
||||
{
|
||||
return Err(TicketCliError::new(
|
||||
"--state active/all cannot be mixed with workflow states",
|
||||
));
|
||||
}
|
||||
|
||||
let mut states = Vec::new();
|
||||
for token in tokens {
|
||||
let state = TicketWorkflowState::parse(token).ok_or_else(|| {
|
||||
TicketCliError::new(format!(
|
||||
"invalid state: {token}; expected active, all, planning, ready, queued, inprogress, done, closed"
|
||||
))
|
||||
})?;
|
||||
if !states.contains(&state) {
|
||||
states.push(state);
|
||||
}
|
||||
}
|
||||
Ok(ListState::States(states))
|
||||
}
|
||||
|
||||
fn parse_list_limit(value: &str) -> Result<usize, TicketCliError> {
|
||||
|
|
@ -1148,7 +1165,7 @@ fn default_author() -> String {
|
|||
}
|
||||
|
||||
fn help_text() -> &'static str {
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state planning|ready|queued|inprogress|done|closed|all] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1533,6 +1550,30 @@ mod tests {
|
|||
assert!(err.to_string().contains("use `yoi ticket close"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_list_defaults_to_active_and_accepts_multi_state_filter() {
|
||||
let default = parse_ticket_args(&args(&["list"])).unwrap();
|
||||
match default {
|
||||
TicketCommand::List(options) => assert_eq!(options.state, ListState::Active),
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
|
||||
let explicit = parse_ticket_args(&args(&["list", "--state", "planning,closed"])).unwrap();
|
||||
match explicit {
|
||||
TicketCommand::List(options) => assert_eq!(
|
||||
options.state,
|
||||
ListState::States(vec![
|
||||
TicketWorkflowState::Planning,
|
||||
TicketWorkflowState::Closed
|
||||
])
|
||||
),
|
||||
other => panic!("unexpected command: {other:?}"),
|
||||
}
|
||||
|
||||
let mixed = parse_ticket_args(&args(&["list", "--state", "active,planning"])).unwrap_err();
|
||||
assert!(mixed.to_string().contains("cannot be mixed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_cli_help_lists_required_commands() {
|
||||
let help = parse_ticket_args(&args(&["--help"])).unwrap();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user