@ -132,7 +132,18 @@ enum Commands {
/// A ticket is ready when its status is not `done` and every ticket it
/// A ticket is ready when its status is not `done` and every ticket it
/// depends on has status `done`. Tickets with no dependencies and status
/// depends on has status `done`. Tickets with no dependencies and status
/// `todo` or `in_progress` are always ready.
/// `todo` or `in_progress` are always ready.
///
/// With an optional `<id>`, restricts results to the dependency subtree of
/// that ticket — only ready tickets that `<id>` depends on (directly or
/// transitively) are returned. The scoping ticket itself is excluded.
Ready {
Ready {
/// Optional ticket ID or unique prefix to scope results to its dependency subtree.
///
/// When provided, only ready tickets within the subtree that `<id>`
/// depends on (directly or transitively) are returned. The ticket
/// identified by `<id>` itself is never included in the results.
id : Option < String > ,
/// Filter ready tickets by field: repeatable `key=value` pairs.
/// Filter ready tickets by field: repeatable `key=value` pairs.
///
///
/// Applied after the ready check — narrows within already-ready tickets.
/// Applied after the ready check — narrows within already-ready tickets.
@ -147,8 +158,20 @@ enum Commands {
/// depends on has status `done`. Returns the single highest-priority
/// depends on has status `done`. Returns the single highest-priority
/// ready ticket, optionally narrowed by `--filter KEY=VALUE`.
/// ready ticket, optionally narrowed by `--filter KEY=VALUE`.
///
///
/// With an optional `<id>`, restricts results to the dependency subtree of
/// that ticket — only the highest-priority ready ticket that `<id>` depends
/// on (directly or transitively) is returned. The scoping ticket itself is
/// excluded.
///
/// Exits 0 even when no ready ticket exists.
/// Exits 0 even when no ready ticket exists.
Next {
Next {
/// Optional ticket ID or unique prefix to scope results to its dependency subtree.
///
/// When provided, only the highest-priority ready ticket within the
/// subtree that `<id>` depends on (directly or transitively) is
/// returned. The ticket identified by `<id>` itself is never returned.
id : Option < String > ,
/// Filter ready tickets: key=value pairs (repeatable).
/// Filter ready tickets: key=value pairs (repeatable).
/// AND between different keys, OR within same key.
/// AND between different keys, OR within same key.
#[ arg(long = " filter " , value_name = " KEY=VALUE " ) ]
#[ arg(long = " filter " , value_name = " KEY=VALUE " ) ]
@ -305,9 +328,9 @@ async fn dispatch(cli: Cli) -> store::Result<()> {
Commands ::Init = > cmd_init ( cli . json ) . await ,
Commands ::Init = > cmd_init ( cli . json ) . await ,
Commands ::Next { filter } = > cmd_next ( filter , cli . json ) . await ,
Commands ::Next { id, filter } = > cmd_next ( id , filter , cli . json ) . await ,
Commands ::Ready { filter } = > cmd_ready ( filter , cli . json ) . await ,
Commands ::Ready { id, filter } = > cmd_ready ( id , filter , cli . json ) . await ,
Commands ::Migrate { dry_run , filter } = > cmd_migrate ( filter , dry_run , cli . json ) . await ,
Commands ::Migrate { dry_run , filter } = > cmd_migrate ( filter , dry_run , cli . json ) . await ,
@ -469,8 +492,17 @@ async fn cmd_init(json: bool) -> store::Result<()> {
/// Missing dependency IDs are treated conservatively — the ticket is **not**
/// Missing dependency IDs are treated conservatively — the ticket is **not**
/// ready if any dep cannot be resolved.
/// ready if any dep cannot be resolved.
///
///
/// When `scope_id` is `Some`, results are restricted to the dependency subtree
/// of the identified ticket — only ready tickets that the scoping ticket depends
/// on (directly or transitively) are returned. The scoping ticket itself is
/// excluded from the results.
///
/// `filter_args` are applied after the ready check, narrowing the results.
/// `filter_args` are applied after the ready check, narrowing the results.
async fn cmd_ready ( filter_args : Vec < String > , json : bool ) -> store ::Result < ( ) > {
async fn cmd_ready (
scope_id : Option < String > ,
filter_args : Vec < String > ,
json : bool ,
) -> store ::Result < ( ) > {
let filter = crate ::filter ::parse_filters ( & filter_args ) ? ;
let filter = crate ::filter ::parse_filters ( & filter_args ) ? ;
let root = find_nbd_root ( ) ? ;
let root = find_nbd_root ( ) ? ;
let all = list_tickets_cached ( & root ) . await ? ;
let all = list_tickets_cached ( & root ) . await ? ;
@ -487,9 +519,34 @@ async fn cmd_ready(filter_args: Vec<String>, json: bool) -> store::Result<()> {
. map ( | t | t . id . as_str ( ) )
. map ( | t | t . id . as_str ( ) )
. collect ( ) ;
. collect ( ) ;
// If a scope ID was provided, resolve it and build the dependency subtree.
// The candidate pool is restricted to tickets within that subtree (excluding
// the scoping ticket itself).
let scope_subtree : Option < std ::collections ::HashSet < String > > = match scope_id {
Some ( raw ) = > {
let resolved = resolve_id ( & root , & raw ) . await ? ;
let graph = TicketGraph ::build ( & all ) ;
// subtree() includes the root itself; exclude it from candidates.
let ids : std ::collections ::HashSet < String > = graph
. subtree ( & resolved )
. into_iter ( )
. filter ( | & id | id ! = resolved . as_str ( ) )
. map ( | id | id . to_string ( ) )
. collect ( ) ;
Some ( ids )
}
None = > None ,
} ;
let ready : Vec < & crate ::ticket ::Ticket > = all
let ready : Vec < & crate ::ticket ::Ticket > = all
. iter ( )
. iter ( )
. filter ( | t | {
. filter ( | t | {
// If a subtree scope was set, only include tickets in that scope.
if let Some ( ref subtree ) = scope_subtree {
if ! subtree . contains ( & t . id ) {
return false ;
}
}
t . status ! = crate ::ticket ::Status ::Done
t . status ! = crate ::ticket ::Status ::Done
& & t . status ! = crate ::ticket ::Status ::Closed
& & t . status ! = crate ::ticket ::Status ::Closed
& & t . status ! = crate ::ticket ::Status ::Archived
& & t . status ! = crate ::ticket ::Status ::Archived
@ -516,10 +573,19 @@ async fn cmd_ready(filter_args: Vec<String>, json: bool) -> store::Result<()> {
/// every dependency has status `done`. Missing dependency IDs make a ticket
/// every dependency has status `done`. Missing dependency IDs make a ticket
/// **not** ready.
/// **not** ready.
///
///
/// When `scope_id` is `Some`, results are restricted to the dependency subtree
/// of the identified ticket — only the highest-priority ready ticket that the
/// scoping ticket depends on (directly or transitively) is returned. The scoping
/// ticket itself is excluded from the results.
///
/// With `--json`, outputs `{"next": {...ticket...}}` when a ticket is found or
/// With `--json`, outputs `{"next": {...ticket...}}` when a ticket is found or
/// `{"next": null}` when none are ready, so callers always receive an object
/// `{"next": null}` when none are ready, so callers always receive an object
/// with a `"next"` key.
/// with a `"next"` key.
async fn cmd_next ( filter_args : Vec < String > , json : bool ) -> store ::Result < ( ) > {
async fn cmd_next (
scope_id : Option < String > ,
filter_args : Vec < String > ,
json : bool ,
) -> store ::Result < ( ) > {
let filter = crate ::filter ::parse_filters ( & filter_args ) ? ;
let filter = crate ::filter ::parse_filters ( & filter_args ) ? ;
let root = find_nbd_root ( ) ? ;
let root = find_nbd_root ( ) ? ;
let all = list_tickets_cached ( & root ) . await ? ; // sorted by priority desc
let all = list_tickets_cached ( & root ) . await ? ; // sorted by priority desc
@ -534,7 +600,32 @@ async fn cmd_next(filter_args: Vec<String>, json: bool) -> store::Result<()> {
. map ( | t | t . id . as_str ( ) )
. map ( | t | t . id . as_str ( ) )
. collect ( ) ;
. collect ( ) ;
// If a scope ID was provided, resolve it and build the dependency subtree.
// The candidate pool is restricted to tickets within that subtree (excluding
// the scoping ticket itself).
let scope_subtree : Option < std ::collections ::HashSet < String > > = match scope_id {
Some ( raw ) = > {
let resolved = resolve_id ( & root , & raw ) . await ? ;
let graph = TicketGraph ::build ( & all ) ;
// subtree() includes the root itself; exclude it from candidates.
let ids : std ::collections ::HashSet < String > = graph
. subtree ( & resolved )
. into_iter ( )
. filter ( | & id | id ! = resolved . as_str ( ) )
. map ( | id | id . to_string ( ) )
. collect ( ) ;
Some ( ids )
}
None = > None ,
} ;
let next = all . iter ( ) . find ( | t | {
let next = all . iter ( ) . find ( | t | {
// If a subtree scope was set, only include tickets in that scope.
if let Some ( ref subtree ) = scope_subtree {
if ! subtree . contains ( & t . id ) {
return false ;
}
}
t . status ! = crate ::ticket ::Status ::Done
t . status ! = crate ::ticket ::Status ::Done
& & t . status ! = crate ::ticket ::Status ::Closed
& & t . status ! = crate ::ticket ::Status ::Closed
& & t . status ! = crate ::ticket ::Status ::Archived
& & t . status ! = crate ::ticket ::Status ::Archived