App-karr
view release on metacpan or search on metacpan
lib/App/karr/Cmd/Dashboard.pm view on Meta::CPAN
our $VERSION = '0.600';
use Moo;
use MooX::Cmd;
use MooX::Options (
usage_string => 'USAGE: karr dashboard [PATH] [--depth N] [--hide-no-board] [--show-no-board] [--json] [--compact]',
);
use Path::Tiny ();
use Term::ANSIColor qw( colored );
use App::karr::Git;
use App::karr::BoardStore;
use App::karr::Role::CliArgs;
use App::karr::Role::ExitCodes;
use App::karr::Role::Output;
use App::karr::Role::CompactOutput;
# Board-less on purpose (ticket #220): this walks a directory tree for
# repositories and opens each board directly through App::karr::Git /
# App::karr::BoardStore, the way App::karr::Foundation/_is_karr_board_root
# does. It composes neither App::karr::Role::BoardDiscovery nor
# App::karr::Role::BoardAccess -- there is no single board to discover here,
# and no --dir either: the positional PATH below already names the search
# root, and a second option with an unrelated meaning would only confuse.
# _reject_root_dir in execute() is what makes that decision hold for the root
# placement as well (#225); without it the option was accepted there and
# silently dropped.
with 'App::karr::Role::CliArgs', 'App::karr::Role::ExitCodes',
'App::karr::Role::Output', 'App::karr::Role::CompactOutput';
option depth => (
is => 'ro',
format => 'i',
default => sub { 4 },
doc => 'Maximum recursion depth below PATH (default 4)',
);
option hide_no_board => (
is => 'ro',
doc => 'Hide the summarized list of repositories with no karr board',
);
option show_no_board => (
is => 'ro',
doc => 'Always list the board-less repositories by name, wrapped over several lines',
);
# Same status -> colour mapping App::karr::Cmd::Board uses (see its own
# comment there): the same status must not look different between the two
# commands of this one distribution.
my %STATUS_COLOR = (
backlog => 'bright_black',
todo => 'cyan',
'in-progress' => 'yellow',
review => 'magenta',
done => 'green',
archived => 'bright_black',
);
# Decision (documented on ticket #220, karr edit 220 -a "..."): blocked cards
# are pulled out of their status colour into their own trailing bar segment,
# always bold red -- the same colour App::karr::Cmd::Board uses for
# `blocked:reason` -- so a blocked card reads as blocked at a glance instead
# of disappearing into whichever status colour it happened to be sitting in.
use constant BLOCKED_COLOR => 'bold red';
# One block character per (unscaled) open task. Decision: capped at 10 blocks
# per bar (see _scale_segments) so a busy board's entry stays short enough for
# several columns to fit side by side at an ordinary 80-column terminal --
# the whole point of "mehrspaltig" being multiple repos per screen row, not
# one very wide repo taking the row on its own.
use constant BLOCK_CHAR => "\x{2588}";
use constant MAX_BAR_BLOCKS => 10;
use constant COLUMN_GAP => 2;
sub execute {
my ($self, $args_ref, $chain_ref) = @_;
$self->_reject_root_dir($chain_ref);
$self->check_positional_args($args_ref, 1);
my ($pos) = $self->positional_args($args_ref);
$self->usage_error( sprintf '--depth must be 0 or greater (got %d)', $self->depth )
if $self->depth < 0;
my $start = Path::Tiny::path( defined $pos ? $pos : '.' )->absolute;
$self->usage_error("not a directory: $start") unless $start->is_dir;
my @repo_dirs = sort { "$a" cmp "$b" } $self->_find_repos( $start, $self->depth );
my ( @boards, @no_board );
for my $dir (@repo_dirs) {
my $info = $self->_probe_repo($dir);
if ($info) { push @boards, $info }
else { push @no_board, $dir }
}
@boards = sort { $a->{name} cmp $b->{name} || "$a->{dir}" cmp "$b->{dir}" } @boards;
my $total_open = 0;
$total_open += $_->{open} for @boards;
if ( $self->json ) {
my %doc = (
root => "$start",
summary => {
repos => scalar(@repo_dirs),
boards => scalar(@boards),
open => $total_open,
},
boards => [
map {
my $b = $_;
+{
path => "$b->{dir}",
name => $b->{name},
board_name => $b->{board_name},
open => $b->{open},
blocked => $b->{blocked},
# Named $status, not $_: this is a map nested inside the outer
# one, and reusing $_ here would shadow $b's own $_ alias with
# the status name, breaking $b->{counts}{$_} silently.
statuses => { map { my $status = $_; ( $status => $b->{counts}{$status} // 0 ) } @{ $b->{order} } },
}
} @boards
],
( $self->hide_no_board ? () : ( no_board => [ map { "$_" } @no_board ] ) ),
);
$self->print_json( \%doc );
return;
}
if ( $self->compact ) {
for my $b (@boards) {
my @tokens = map { "$_:" . ( $b->{counts}{$_} // 0 ) } @{ $b->{order} };
push @tokens, 'blocked:' . $b->{blocked};
printf "%s\t%s\n", $b->{name}, join( ',', @tokens );
}
unless ( $self->hide_no_board ) {
printf "%s\tno-board\n", $_->basename for @no_board;
}
return;
}
my $color = -t STDOUT && !$ENV{NO_COLOR};
my $c = sub {
my ( $text, $spec ) = @_;
return $color ? colored( $text, $spec ) : $text;
};
# Every line printed below is fitted to this one width. Nothing may exceed
# it: a line that is one character too long soft-wraps in the terminal, and
# a soft-wrapped grid row destroys the column alignment this command exists
# for -- worse than a plain one-column list would have been.
my $width = $self->_term_width;
print $c->( $self->_truncate( "Dashboard: $start", $width ), 'bold cyan' ), "\n\n";
if (@boards) {
my $name_width = 0;
$name_width = length( $_->{name} ) > $name_width ? length( $_->{name} ) : $name_width
for @boards;
# The count column is the only other variable-width part; measure it so
# the name cap below is exact rather than guessed.
my $count_w = 0;
for my $b (@boards) {
my $l = length( '(' . $b->{open} . ')' );
$count_w = $l if $l > $count_w;
}
# A cell is name + 2 spaces + bar + 1 space + count. On a terminal too
# narrow to hold even one whole cell the grid falls back to a single
# column, and a single column cannot shrink below its own content -- so
# the content itself has to shrink. Names are capped first (they are the
# only unbounded part), then the bar, so both stay inside $width. At any
# ordinary width neither cap is reached and nothing is truncated.
my $name_cap = $width - 3 - MAX_BAR_BLOCKS - $count_w;
$name_cap = 4 if $name_cap < 4;
$name_width = $name_cap if $name_width > $name_cap;
my $bar_max = $width - $name_width - 3 - $count_w;
$bar_max = 1 if $bar_max < 1;
$bar_max = MAX_BAR_BLOCKS if $bar_max > MAX_BAR_BLOCKS;
my @cells = map { $self->_entry_for_board( $_, $name_width, $bar_max, $c ) } @boards;
print $self->_render_grid( \@cells, $width );
}
else {
print $c->( '(no boards found)', 'bright_black' ), "\n";
}
# The board-less repositories are a footnote, and on a big tree there are
# more of them than boards (46 of 91 under one real /home/getty/dev scan).
# Joined into one line that was 837 characters -- seven soft-wrapped
# terminal lines that buried the summary underneath them. Three cases now:
# it fits on one line, or --show-no-board wraps it properly, or it collapses
# to a count that says how to see the names.
unless ( $self->hide_no_board || !@no_board ) {
my @names = map { $_->basename } @no_board;
my $one_line = 'No board: ' . join( ', ', @names );
print "\n";
if ( length($one_line) <= $width ) {
print $c->( $one_line, 'bright_black' ), "\n";
}
elsif ( $self->show_no_board ) {
print $c->( $_, 'bright_black' ), "\n"
for $self->_wrap_items( 'No board: ', \@names, $width, 10 );
}
else {
# The hint is worth a line only while it fits beside the count; on a
# narrow terminal the count alone is what survives.
my $hint = sprintf( 'No board: %d repos (--show-no-board to list them)', scalar @names );
my $short = sprintf( 'No board: %d repos', scalar @names );
$hint = $short if length($hint) > $width;
print $c->( $self->_truncate( $hint, $width ), 'bright_black' ), "\n";
}
}
print "\n", $c->(
$self->_truncate(
sprintf( '%d repos %d boards %d open', scalar(@repo_dirs), scalar(@boards), $total_open ),
$width ),
'bold'
), "\n";
}
# `karr dashboard --dir PATH` was always rejected by MooX::Options (this
# command declares no such option) -- but `karr --dir PATH dashboard` was not:
# --dir is declared on App::karr::Role::BoardDiscovery, the root command
# composes it via BoardAccess, and MooX::Cmd leaves the parsed value on the
# root instance in the command chain, where nothing here ever looked. So the
# option was swallowed without a word and the scan ran on the current
# directory instead -- and a list of repositories with counts behind them
# looks like a valid answer no matter which tree it came from (#225).
#
# It is refused rather than adopted because the two paths are not the same
# path: --dir seeds a walk UPWARD to one repository's root
# (App::karr::Role::BoardDiscovery/_build_git_root, which is why it may name
# any directory inside the target repository), while the positional PATH here
# is the root of a walk DOWNWARD across a tree of repositories (_find_repos,
# bounded by --depth, never entering a repository's own work tree). Handed the
# same argument, the two would answer about different directories.
#
# The root is read from $chain_ref the way App::karr::Cmd::GetRefs reads it
# for the opposite purpose; a directly constructed instance (no MooX::Cmd
# dispatch, hence an empty chain) has no root option to reject.
sub _reject_root_dir {
my ($self, $chain_ref) = @_;
return unless $chain_ref && @$chain_ref;
my $root = $chain_ref->[0];
return unless $root && $root->can('has_dir') && $root->has_dir;
# Wrapped to stay inside 80 columns with usage_error's own "Usage error: "
# prefix on the first line: what to type comes first, the reason after it.
$self->usage_error(
"dashboard does not take --dir; its scan root is an argument:\n"
. "karr dashboard PATH\n"
. "(--dir seeds the search upward for one repository's board, while dashboard\n"
. "searches downward for every board under a directory.)"
);
}
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
# Iterative (not recursive) so a very deep or wide tree cannot blow the Perl
# call stack; order within a directory is irrelevant to the caller, which
# sorts the whole result afterwards.
sub _find_repos {
my ( $self, $start, $max_depth ) = @_;
my @found;
my @stack = ( [ $start, 0 ] );
while (@stack) {
my ( $dir, $depth ) = @{ shift @stack };
if ( $dir->child('.git')->exists ) {
push @found, $dir;
next; # never search inside a repository's own working tree
}
next if $depth >= $max_depth;
my @children = eval { grep { $_->is_dir } $dir->children };
( run in 0.884 second using v1.01-cache-2.11-cpan-aadc1410aed )