App-karr

 view release on metacpan or  search on metacpan

t/185-foundation-ticket-mode.t  view on Meta::CPAN

use strict;
use warnings;
use Test::More;
use Path::Tiny qw( path tempdir );
use Time::Piece;

use App::karr::Foundation;
use App::karr::Foundation::Picker;
use App::karr::Git;
use App::karr::BoardStore;
use App::karr::Task;

# Ticket #185: `mode: ticket` runs the agent once, about one card foundation
# names, and comes back. What is tested here is the difference to `drain:
# false`, which also runs once: the run is *about* a ticket. So every assertion
# below is about which card was chosen, how the agent was told, and what the
# result says about that card -- not about the number of runs alone.

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

sub make_git_repo {
  my $dir = tempdir( CLEANUP => 1 );
  system( 'git', '-C', "$dir", 'init', '-q' ) == 0 or die "git init";
  system( 'git', '-C', "$dir", 'config', 'user.email', 'a@b.invalid' ) == 0 or die;
  system( 'git', '-C', "$dir", 'config', 'user.name', 'T' ) == 0 or die;
  return $dir;
}

sub seed_board {
  my ( $repo, @specs ) = @_;
  my $git   = App::karr::Git->new( dir => "$repo" );
  my $store = App::karr::BoardStore->new( git => $git );
  for my $spec ( @specs ) {
    my $id = $store->allocate_next_id;
    $store->save_task( App::karr::Task->new( id => $id, title => "task $id", %$spec ) );
  }
  return $store;
}

sub task_by_id {
  my ( $repo, $id ) = @_;
  my $git = App::karr::Git->new( dir => "$repo" );
  return App::karr::BoardStore->new( git => $git )->find_task( $id );
}

# One line per invocation holding the id the run was given, so a test can prove
# both which card was named and how many agent runs happened -- and the prompt
# of the last run, which is the other half of how the assignment travels.
sub write_fake_agent {
  my ( $dir ) = @_;
  my $lib    = path('lib')->absolute->stringify;
  my $script = path($dir)->child('fake-agent.pl');
  $script->spew_utf8(<<'PERL');
use strict;
use warnings;
my $repo = $ENV{KARR_REPO} or die "no KARR_REPO\n";
my $mode = $ENV{KARR_FAKE_MODE} // 'assigned';
path_append( "$repo/runs.log", ( $ENV{KARR_TASK} // '' ) . "\n" );
path_write( "$repo/prompt.txt", $ENV{PROMPT} // '' );
exit 0 if $mode eq 'idle';

require App::karr::Git;
require App::karr::BoardStore;
require App::karr::ActivityLog;
my $store = App::karr::BoardStore->new(
  git => App::karr::Git->new( dir => $repo ) );

my $target;
if ( $mode eq 'assigned' ) {
  my $id = $ENV{KARR_TASK} or die "no KARR_TASK\n";
  $target = $store->find_task($id) or die "no task $id\n";
}
else {
  # 'other': do a card that is not the one this run was given. A board that
  # moved is not a ticket that moved.
  my @open = grep {
    $_ && !$_->has_blocked && $_->status ne 'done' && $_->status ne 'archived'
      && $_->id ne ( $ENV{KARR_TASK} // '' )
  } $store->load_tasks;
  $target = $open[0] or die "nothing else to do\n";
}

$target->status('done');
$store->save_task($target);
App::karr::ActivityLog->new( git => $store->git, role => 'agent' )->log_entry(
  agent   => 'fake-agent',
  action  => 'move',
  task_id => $target->id + 0,
  detail  => 'done',
);

sub path_append { my ( $f, $t ) = @_; open my $fh, '>>', $f or die $!; print {$fh} $t; close $fh }
sub path_write  { my ( $f, $t ) = @_; open my $fh, '>',  $f or die $!; print {$fh} $t; close $fh }
PERL
  return qq{$^X -I"$lib" "$script"};
}

sub runs_of {
  my ( $repo ) = @_;
  my $log = path( $repo )->child('runs.log');
  return () unless $log->exists;
  return grep { defined } split /\n/, $log->slurp_utf8;
}

# ---------------------------------------------------------------------------
# Unit: run mode, and the one key it has to share with the old one
# ---------------------------------------------------------------------------

subtest 'mode resolution, with drain: as the older spelling' => sub {
  my $f = App::karr::Foundation->new( _config_data => {} );

  is $f->_run_mode( {} ),                  'drain',  'default is drain';
  is $f->_run_mode( { drain => 1 } ),      'drain',  'drain: true  => drain';
  is $f->_run_mode( { drain => 0 } ),      'single', 'drain: false => single';
  is $f->_run_mode( { mode => 'ticket' } ), 'ticket', 'mode: ticket';
  is $f->_run_mode( { mode => 'drain', drain => 0 } ), 'drain',
    'mode wins over the older drain key in the same file';

  my $g = App::karr::Foundation->new( _config_data => { mode => 'ticket' } );
  is $g->_run_mode( {} ), 'ticket', 'config-wide mode reaches a repo of its own';
  is $g->_run_mode( { drain => 0 } ), 'single',
    'per-repo drain: false beats a config-wide mode';

  my $err = do { local $@; eval { $f->_run_mode( { mode => 'ticekt' } ) }; $@ };
  like $err, qr/Unknown mode 'ticekt'/,
    'a typo is an error, never a silent fall back to draining the board';
};

# ---------------------------------------------------------------------------
# Unit: which card a ticket-mode run is about
# ---------------------------------------------------------------------------

subtest 'ticket selection uses pick eligibility and pick ranking' => sub {
  my $repo = make_git_repo();
  my $f    = App::karr::Foundation->new( _config_data => {} );

  is $f->_select_ticket( $repo ), undef, 'empty board assigns nothing';

  seed_board( $repo,
    { status => 'todo',  priority => 'low' },                        # 1
    { status => 'done',  priority => 'critical' },                   # 2
    { status => 'todo',  priority => 'critical', blocked => 'why' }, # 3
    { status => 'todo',  priority => 'high' },                       # 4
    { status => 'todo',  priority => 'critical',                     # 5
      claimed_by => 'somebody', claimed_at => gmtime->datetime . 'Z' },
  );

  is $f->_select_ticket( $repo ), 4,
    'terminal, blocked and live-claimed cards are all passed over';

  # The crashed-agent case the mode exists for: a claim older than the board's
  # claim_timeout no longer holds the card, or a board whose last open ticket
  # carries one would go quiet for ever -- nothing would run, so nothing would
  # ever reap the claim.
  my $store = App::karr::BoardStore->new(
    git => App::karr::Git->new( dir => "$repo" ) );
  my $stale = $store->find_task( 5 );
  $stale->claimed_at( gmtime( time - 7200 )->datetime . 'Z' );
  $store->save_task( $stale );

  is $f->_select_ticket( $repo ), 5,
    'an expired claim is taken over, and critical outranks high';
};

# ---------------------------------------------------------------------------
# Unit: how the assignment reaches the agent
# ---------------------------------------------------------------------------

subtest 'the ticket travels in $PROMPT and $KARR_TASK' => sub {
  my $f = App::karr::Foundation->new( _config_data => {} );

  my $plain = $f->_prompt_for( {} );
  unlike $plain, qr/task for this run/, 'no assignment without a ticket';

  my $assigned = $f->_prompt_for( {}, 42 );
  like $assigned, qr/task for this run is #42/, 'the id is in the prompt';
  unlike $assigned, qr/pick the next actionable task/,
    'the built-in default no longer tells the agent to choose its own work';

  my $custom = $f->_prompt_for( { prompt => 'CUSTOM' }, 42 );
  like $custom, qr/\ACUSTOM\n\nThe task for this run is #42/,
    'a configured prompt is kept, with the assignment appended after it';

  my $rdir = tempdir( CLEANUP => 1 );
  my ( undef, $out ) = $f->_run_command( $rdir, {}, 'printf "[%s]" "$KARR_TASK"' );
  is $out, '[]', 'KARR_TASK is empty outside ticket mode';
  my ( undef, $out2 ) = $f->_run_command( $rdir, {}, 'printf "[%s]" "$KARR_TASK"', 7 );
  is $out2, '[7]', 'and carries the id in it';
};

# ---------------------------------------------------------------------------
# Integration: one run, one ticket, and back
# ---------------------------------------------------------------------------

subtest 'ticket mode runs the agent once, on the card it named' => sub {
  my $repo = make_git_repo();
  seed_board( $repo,
    { status => 'todo', priority => 'low' },
    { status => 'todo', priority => 'high' },
    { status => 'todo', priority => 'low' },
  );
  my $agent = write_fake_agent( $repo );

  my $f = App::karr::Foundation->new( _config_data => {} );
  local $ENV{KARR_FAKE_MODE} = 'assigned';
  my $res = $f->_drain_repo( $repo,
    { command => $agent, max_runtime => 60, mode => 'ticket' } );

  is $res->{ticket},  2,          'the run reports which ticket it was about';
  is $res->{outcome}, 'progress', 'the ticket moved';

  my @runs = runs_of( $repo );
  is scalar @runs, 1, 'exactly one agent run, board still actionable or not';
  is $runs[0], '2', 'the agent was handed the id foundation picked';

  is task_by_id( $repo, 2 )->status, 'done', 'the assigned card is done';
  is task_by_id( $repo, 1 )->status, 'todo', 'the rest of the board is untouched';
  is task_by_id( $repo, 3 )->status, 'todo', 'no drain happened';

  like path( $repo )->child('prompt.txt')->slurp_utf8, qr/task for this run is #2/,
    'the prompt the agent saw named the card';
};

# ---------------------------------------------------------------------------
# Integration: nothing to assign is not a reason to start an agent
# ---------------------------------------------------------------------------

subtest 'no assignable card means no agent run at all' => sub {
  my $repo = make_git_repo();
  seed_board( $repo, { status => 'done' }, { status => 'todo', blocked => 'why' } );
  my $agent = write_fake_agent( $repo );

  my $f = App::karr::Foundation->new( _config_data => {} );
  local $ENV{KARR_FAKE_MODE} = 'assigned';
  my $res = $f->_drain_repo( $repo,
    { command => $agent, max_runtime => 60, mode => 'ticket' } );

  is $res->{outcome}, 'idle',  'idle, not an error';
  is $res->{ticket},  undef,   'and no ticket to report';
  ok !path( $repo )->child('runs.log')->exists, 'the agent was never started';
  like path( $repo )->child('.karr.log')->slurp_utf8, qr/TICKET none assignable/,
    'the log says why nothing ran';
};

# ---------------------------------------------------------------------------
# Integration: the run is judged by its own card
# ---------------------------------------------------------------------------

subtest 'a board that moved is not a ticket that moved' => sub {
  my $repo = make_git_repo();
  seed_board( $repo,
    { status => 'todo', priority => 'high' },
    { status => 'todo', priority => 'low' },
  );
  my $agent = write_fake_agent( $repo );

  my $f = App::karr::Foundation->new( _config_data => {} );
  local $ENV{KARR_FAKE_MODE} = 'other';
  my $res = $f->_drain_repo( $repo,
    { command => $agent, max_runtime => 60, mode => 'ticket', max_attempts => 2 } );

  is $res->{ticket}, 1, 'ticket 1 was assigned';
  is task_by_id( $repo, 2 )->status, 'done', 'the agent did move the board';
  is $res->{outcome}, 'stall',
    'but its own ticket did not move, so the run stalled';
  is $f->_state_get( $repo, 'attempts' )->{1}, 1,
    'the assigned card is the one that loses the attempt';
};

# ---------------------------------------------------------------------------
# Integration: an agent that comes back with nothing costs one attempt
# ---------------------------------------------------------------------------

subtest 'a stalled ticket is auto-blocked at max_attempts' => sub {
  my $repo = make_git_repo();
  seed_board( $repo, { status => 'todo' } );
  my $agent = write_fake_agent( $repo );

  my $f = App::karr::Foundation->new( _config_data => {} );
  local $ENV{KARR_FAKE_MODE} = 'idle';
  my $karr = { command => $agent, max_runtime => 60, mode => 'ticket', max_attempts => 2 };



( run in 0.430 second using v1.01-cache-2.11-cpan-ff9377addf4 )