App-karr
view release on metacpan or search on metacpan
lib/App/karr/Cmd/List.pm view on Meta::CPAN
# ABSTRACT: List tasks with filtering and sorting
package App::karr::Cmd::List;
our $VERSION = '0.601';
use Moo;
use MooX::Cmd;
use MooX::Options (
usage_string => 'USAGE: karr list [--status LIST] [--priority LIST] [--archived] [--sort FIELD] [--limit N] [--group-by FIELD] [options]',
);
use App::karr::Role::BoardAccess;
use App::karr::Role::Output;
use App::karr::Role::CompactOutput;
use App::karr::Board;
# For --unclaimed, and for nothing else: claim_held is the claim test
# App::karr::Role::PickRules/pickable applies, so the free cards this command
# lists are the free cards `karr pick` hands out (ticket #252). The role is
# composed rather than the predicate rewritten here, which is the whole point
# of the option. The rest of what it brings -- check_claim and its reporting
# half -- belongs to the mutating commands; `list` writes nothing and never
# calls it.
use App::karr::Role::ClaimTimeout;
use App::karr::Role::ClaimDefault;
use App::karr::Task;
use App::karr::Config;
use App::karr::Error qw( user_error command_hint );
with 'App::karr::Role::BoardAccess', 'App::karr::Role::Output',
'App::karr::Role::CompactOutput', 'App::karr::Role::ClaimTimeout',
'App::karr::Role::ClaimDefault';
option status => (
is => 'ro',
format => 's',
doc => 'Filter by status (comma-separated)',
);
option priority => (
is => 'ro',
format => 's',
doc => 'Filter by priority (comma-separated)',
);
option assignee => (
is => 'ro',
format => 's',
doc => 'Filter by assignee',
);
option tag => (
is => 'ro',
format => 's',
doc => 'Filter by tag',
);
option search => (
is => 'ro',
format => 's',
short => 's',
doc => 'Search tasks by title, body, or tags',
);
option claimed_by => (
is => 'ro',
format => 's',
doc => 'Filter by claim owner',
);
# The complete set of --sort keys, in the order the usage message lists them.
# Single source for the option doc, the usage message, and _comparators.
my @SORT_FIELDS = qw( id title status priority created updated due );
option sort => (
is => 'ro',
format => 's',
default => sub { 'id' },
doc => 'Sort by: ' . join(', ', @SORT_FIELDS),
);
option reverse => (
is => 'ro',
short => 'r',
doc => 'Reverse sort order',
);
option archived => (
is => 'ro',
doc => 'Show only archived tasks',
lib/App/karr/Cmd/List.pm view on Meta::CPAN
die $err . qq{ -- "$value" is a filter flag, not a status:\n}
. command_hint( 'list', $flag ) . "\n";
}
}
if ( defined $self->priority ) {
$self->config->validate_priority($_) for split /,/, $self->priority;
}
}
sub _load_tasks {
my ($self) = @_;
return $self->load_tasks;
}
sub _filter {
my ($self, $tasks, $timeout) = @_;
my @filtered = @$tasks;
# Which statuses were asked for, if any. --archived is a status filter and
# nothing more, exactly as in kanban-md (cmd/list.go): it replaces --status
# rather than intersecting with it, and every other filter below still
# applies on top, so `--archived --tag legacy` means what it reads like.
my $wanted;
if ($self->archived) {
$wanted = { App::karr::Config->ARCHIVED_STATUS => 1 };
} elsif ($self->status) {
$wanted = { map { $_ => 1 } split /,/, $self->status };
}
# Nothing asked for: hide the board's terminal statuses, so the default view
# is open work. Asked of the store, so a board whose final column is
# `shipped` hides shipped work instead of the `done` it does not have
# (ticket #67).
if ($wanted) {
@filtered = grep { $wanted->{$_->status} } @filtered;
} else {
@filtered = grep { !$self->store->is_terminal_status($_->status) } @filtered;
}
if ($self->priority) {
my %priorities = map { $_ => 1 } split /,/, $self->priority;
@filtered = grep { $priorities{$_->priority} } @filtered;
}
if ($self->assignee) {
@filtered = grep { $_->has_assignee && $_->assignee eq $self->assignee } @filtered;
}
if ($self->tag) {
@filtered = grep {
my $t = $_;
grep { $_ eq $self->tag } @{$t->tags};
} @filtered;
}
# --claimed-by defaults to KARR_CLAIM when omitted (ADR 0005), so an agent
# that exported it sees its own cards from a bare `karr list`. --unclaimed asks
# the opposite question and wins outright: the env default is suppressed under
# it, and an explicit --claimed-by alongside --unclaimed was already rejected
# as a usage error above, so this only steps aside for the env-supplied value.
my $claimed_by = $self->unclaimed ? undef : $self->resolved_claimed_by;
if ( defined $claimed_by && length $claimed_by ) {
@filtered = grep { $_->has_claimed_by && $_->claimed_by eq $claimed_by } @filtered;
}
# The claim test itself is App::karr::Role::ClaimTimeout/claim_held -- the
# one App::karr::Role::PickRules/pickable applies -- so this list and `karr
# pick` cannot come to disagree about which cards are free (#59, #198, #252).
# One window for the whole run, read once here rather than per card: a
# board-wide filter that re-read claim_timeout for every card could in
# principle straddle a config change mid-list, and would certainly do the
# parse N times.
#
# Claim only, as kanban-md's IsUnclaimed is: pickable goes on to exclude
# blocked and terminal cards, and neither is a statement about who holds the
# card. --blocked --unclaimed is a real triage query here, not an empty one.
# The window was read once in execute and is passed in, so the filter and
# the table's claim display cannot judge the same run against two timeouts.
if ($self->unclaimed) {
@filtered = grep { !$self->claim_held( $_, $timeout ) } @filtered;
}
# Plain equality against the card's class, which App::karr::Task always has
# (it defaults to `standard`), so there is no unset case to fold in. The
# value was checked against the board's classes in _validate_options, so an
# empty result here means the board has no card of that class -- not that the
# class was misspelled.
if (defined $self->class) {
@filtered = grep { $_->class eq $self->class } @filtered;
}
# has_blocked is the whole test on both sides: L<App::karr::Task/BUILD>
# normalizes the field so the predicate is true exactly when the card is
# blocked, and `blocked: false` from a kanban-md document is not
# representable as "set but off" (ticket #58). The pair is mutually
# exclusive, rejected in _validate_options, so the elsif cannot hide a
# second filter from anybody.
if ($self->blocked) {
@filtered = grep { $_->has_blocked } @filtered;
} elsif ($self->not_blocked) {
@filtered = grep { !$_->has_blocked } @filtered;
}
if ($self->search) {
my $q = lc($self->search);
@filtered = grep {
index(lc($_->title), $q) >= 0
|| index(lc($_->body), $q) >= 0
|| grep { index(lc($_), $q) >= 0 } @{$_->tags}
} @filtered;
}
return @filtered;
}
# Cut to --limit, last of the three stages and deliberately after the sort.
# 0 -- the default -- is no limit rather than an empty list, matching
# kanban-md's `if opts.Limit > 0` (internal/board/board.go); a negative value
# never reaches here, _validate_options refuses it. The cut is in execute
# rather than in either output branch so --json, --compact and the table all
# see the same N tasks: a --limit that only applied to the human table would be
# a limit exactly where the context it saves does not matter.
sub _limit {
my ($self, $tasks) = @_;
my $limit = $self->limit;
return @$tasks unless $limit > 0 && @$tasks > $limit;
return @{$tasks}[ 0 .. $limit - 1 ];
}
sub _sort {
lib/App/karr/Cmd/List.pm view on Meta::CPAN
include)> -- with the same count C<karr board>'s footer prints.
=head1 FILTERS AND SORTING
=over 4
=item * C<--status>, C<--priority>
Accept comma-separated lists and only return tasks matching one of the
requested values. Every element is checked against the board's config before
anything is filtered: a status or priority the board does not know is a usage
error (exit C<2>) naming the ones it does know, the same answer C<karr create
--status>/C<--priority> gives -- kanban-md compares the strings and prints an
empty list instead, which reads like "no such work" when the truth is "no such
status". C<--status> additionally accepts C<archived>, which is a real status
karr hardcodes even on a board that does not configure a column for it. When the
rejected value is C<blocked> or C<not-blocked> -- filter flags, not statuses --
the message also names C<karr list --blocked>/C<--not-blocked>, the trap an
agent reaching for C<--status> falls into (ticket k290).
=item * C<--archived>
Shows the archive and nothing else. It is a status filter, so it replaces
C<--status> rather than intersecting with it -- matching kanban-md's flag of
the same name -- while the remaining filters still narrow the result.
=item * C<--assignee>, C<--tag>, C<--claimed-by>
Limit the result set to a specific assignee, tag, or claim owner.
=item * C<-s>, C<--search>
Performs a case-insensitive substring search across title, body, and tags.
=item * C<--class>
Limits the result to one class of service. A class the board does not
configure is a usage error (exit C<2>) naming the classes it does configure,
the same answer C<karr create --class> gives -- kanban-md compares the string
and prints an empty list instead, which reads like "no such work" when the
truth is "no such class". Note that C<list> does not render the class, so the
filter narrows on a field only C<--json> and C<karr show> display; the
validation is what keeps a typo from looking like an empty board.
=item * C<--blocked>, C<--not-blocked>
Show only the blocked cards, or only the unblocked ones. C<blocked> is what
the meta column already prints, so this narrows on something visible.
Passing both is a usage error (exit C<2>): kanban-md lets C<--blocked> win
silently, and karr refuses a self-contradicting invocation instead, as it
does for C<edit --claim --release> and C<move --next --prev> (ticket #235).
=item * C<--unclaimed>
Shows only the cards nobody is holding right now -- C<claimed_by> unset or
empty, or set to a claim older than the board's C<claim_timeout>. It is the
answer to "what is free" that until now only C<karr pick> could give, and
C<pick> answers it by B<taking> the card.
The test is not a second reading of the field: this filter calls
L<App::karr::Role::ClaimTimeout/claim_held>, the same method
L<App::karr::Role::PickRules/pickable> calls, so a card C<list --unclaimed>
shows is a card C<karr pick> can hand out. It asks about the claim and nothing
else, matching kanban-md's C<IsUnclaimed>: blocked cards and cards with unmet
dependencies are unpickable but not claimed, so they are still listed, and
C<--blocked --unclaimed> is a real query rather than an empty one. On a board
with C<claim_timeout: 0s> no claim ever expires, so there C<--unclaimed> means
C<claimed_by> empty and nothing more.
C<--unclaimed> is not the negation of C<--claimed-by>, which is where #237's
reading of the pair went wrong. C<--claimed-by NAME> is an exact string match
on the field and matches an B<expired> claim too, because the name stays on
the card until something re-stamps it; C<--unclaimed> is about who holds the
card now. Passing both is a usage error (exit C<2>) -- see the comment in
C<_validate_options> for why that is the answer even though the two do have a
common case.
=item * C<--sort>, C<--reverse>
Sort by C<id>, C<title>, C<status>, C<priority>, C<created>, C<updated>, or
C<due>, and optionally reverse the result order. Any other field is a usage
error (exit C<2>).
C<status> follows the board config's own order. C<priority> deliberately
reads the config list the other way, most urgent first: C<--sort priority>
lists C<critical> before C<low> with the default C<priorities> setting, so
the top of a priority-sorted list is the task L<App::karr::Cmd::Pick> would
hand out, and C<--reverse> gives the least-urgent-first view. kanban-md's
ascending config order opened the list with the least urgent task when karr
took this direction; it has since made the same change, so the two agree.
C<title> compares case-insensitively, as kanban-md does, so C<Apple> sorts
before C<banana> rather than ahead of every lowercase title. The comparison
is on characters and not collated, so a title starting outside ASCII sorts
after every ASCII one.
B<Collation is a non-goal, not a gap.> C<--sort title> is C<lc> plus a
codepoint compare, and it stays that way: C<Aebi>, C<Zebra>, C<Abi> sorts
C<Abi>, C<Aebi>, C<Zebra> under German rules and C<Abi>, C<Zebra>, C<Aebi>
here. What the option promises is a stable, reproducible order that agrees
with kanban-md on the same board -- not a locale-correct one. A collating
sort would need a locale to collate B<for>, and a board is read by agents on
machines that share none; two hosts would then disagree about what
C<--sort title --limit 5> returns. Anyone who needs alphabetical order for a
human takes C<--json> and sorts it where the locale is known.
Tasks without a C<due> date sort last. Ties are broken by C<id>, and
C<--reverse> turns the finished list around, tied entries with it.
=item * C<-n>, C<--limit>
Keeps at most N tasks, applied after filtering B<and> after sorting -- so
C<--sort priority --limit 5> is the five most urgent open cards, not five
arbitrary ones put in order. C<0>, the default, means no limit. A negative
value is a usage error (exit C<2>) rather than kanban-md's silent "unlimited".
The cut applies to C<--json> and C<--compact> exactly as it does to the table.
This is not C<--last>, which C<karr show> and C<karr log> use for a different
question: C<--last N> is the N most recent by time, C<--limit N> is the head
of whatever C<--sort> just produced. C<karr list --sort updated --reverse
--limit 5> is how this command spells the former.
( run in 1.900 second using v1.01-cache-2.11-cpan-85d3896f969 )