App-karr

 view release on metacpan or  search on metacpan

lib/App/karr/Role/TaskMutation.pm  view on Meta::CPAN

# ABSTRACT: The one guarded path for changing an existing task

package App::karr::Role::TaskMutation;
our $VERSION = '0.601';
use Moo::Role;
# No Time::Piece here on purpose: this role never asks for the time itself --
# the lifecycle stamps are set by App::karr::Task::update_timestamps, which
# loads its own -- and `use Time::Piece;` composed its localtime/gmtime
# replacements into move, edit, delete, archive and handoff for nothing (#105).
use App::karr::Task;
use App::karr::Config;
# Loaded without importing, for the reason spelled out in
# App::karr::Role::Output: a Moo::Role composes every sub in its package into
# its consumers, imported ones included, so `use ... qw( user_error )` here
# would quietly make user_error a method on move, edit, delete, archive and
# handoff.
use App::karr::Error ();
# Same reason, one module over: `use Scalar::Util qw( refaddr );` here would put
# a refaddr method on every command that composes this role.
use Scalar::Util ();
use App::karr::Role::ClaimTimeout;
use App::karr::Role::DependencyCheck;

with 'App::karr::Role::ClaimTimeout', 'App::karr::Role::DependencyCheck';

# What this role calls on its consumer, said out loud (ticket #141; the rule is
# ticket #128's). It declared nothing at all until then, and got away with it
# only because every command on the mutation path composes the roles that supply
# these: git and store from App::karr::Role::BoardDiscovery, save_task and
# log_task_write from App::karr::Role::BoardAccess, json from
# App::karr::Role::Output. Same accident App::karr::Role::DependencyCheck lived
# on before #128, one module over -- and a worse one to leave standing, because
# this role is how a command reaches update_task_guarded without ever naming the
# collaborators that path needs.
#
# Two of the calls below are deliberately not on the list: check_claim and
# check_dependencies come from the two roles composed above, so they are this
# role's own methods and not the consumer's. Requiring one of them would be
# worse than redundant -- it could never fail. Role::Tiny installs a role's
# methods into the consumer *before* it checks the requires
# (role_application_steps), so the check would find the name the composition had
# just put there, in every consumer, always, and read as a guarantee that is not
# one.
#
# json is declared here and on App::karr::Role::DependencyCheck both. The
# duplication is intended: run_batch reads $self->json for its own per-id
# warnings, and a role that lets a role it happens to compose declare a
# collaborator on its behalf is the arrangement this ticket is about.
requires qw( git store save_task log_task_write json );


# One batch loop for every command that takes ID[,ID,...].
#
# `move`, `edit` and `delete` used to die on the first missing id from inside
# the loop, which skipped every id after it: `move 1,999,2` moved 1 and never
# looked at 2, while `move 999,1,2` moved nothing. Which ids survived depended
# on where the bad one sat in the list. `archive` was the only one that already
# warned and carried on, and it is the shape ADR 0002 settled on: "partial
# success is committed, the exit code reports the failure (1)" -- the same
# contract as kanban-md's runBatch (cmd/root.go), which attempts every id,
# prints the per-id failures, and still returns 1 if any of them failed
# (ticket #61).
#
# A usage error is deliberately NOT a per-id failure. `move 1,2,3 bogus-status`
# is wrong for every id at once, so it aborts the batch untouched and keeps its
# exit code of 2 (ticket #54's rule): collecting it would report the same
# message once per id and demote the exit code to 1, which is precisely the
# distinction the exit-code contract exists to make. The markers come from
# App::karr::Error rather than a second copy of bin/karr's list, so a new marker
# on either side cannot silently reclassify a batch.
sub run_batch {
    my ($self, $ids, $per_id) = @_;

    my @results;
    my $failed = 0;

    for my $id (@$ids) {
        my @out;
        my $err = do {
            local $@;
            eval { @out = $per_id->($id); 1 } ? undef : ( $@ || 'unknown error' );
        };

        if ( defined $err ) {

lib/App/karr/Role/TaskMutation.pm  view on Meta::CPAN

    # update_task_guarded's callback, which re-runs on contention, so the
    # emitting is left to dependency_report after the write has landed.
    $self->check_dependencies( $task, $new_status );

    $task->status($new_status);
    # The lifecycle rules themselves live on the task, mirroring kanban-md's
    # internal/task/lifecycle.go: `started` on the first move out of the first
    # configured status, `completed` on any terminal status, and `completed`
    # cleared again when a task is reopened.
    #
    # The board's own config goes with it, so "terminal" means this board's
    # last column and not the literal `done`: on a board that ends in
    # `shipped`, move/edit/archive/handoff recorded no completion at all
    # (left over from ticket #67).
    $task->update_timestamps( $old_status, $new_status, ( $config->statuses )[0],
        $config );

    return $old_status;
}


sub claim_hint_tokens {
    my ( $self, $task, $status ) = @_;
    return ( 'edit', $task->id, '--status', $status, '--claim', 'NAME' );
}


1;

__END__

=pod

=encoding UTF-8

=head1 NAME

App::karr::Role::TaskMutation - The one guarded path for changing an existing task

=head1 VERSION

version 0.601

=head1 DESCRIPTION

Commands that change a task that already exists -- C<move>, C<edit>, C<delete>,
C<archive>, C<handoff> -- share three things through this role: the
compare-and-swap loop that persists the change, the single implementation of
"this task's status becomes that", and the batch loop the id-list commands run
that pair over.

Claim ownership is checked by the caller, inside the callback it hands to
C<update_task_guarded>, rather than by C<update_task_guarded> itself, because
C<edit --release> deliberately acts on somebody else's claim. Putting the check
in the callback is what keeps it under the same guard as the write: a check
made before the loop is a check made against a revision that may no longer be
there (tickets #44, #46, #56).

=head1 SEE ALSO

L<karr>, L<App::karr>, L<App::karr::Role::ClaimTimeout>,
L<App::karr::Cmd::Move>, L<App::karr::Cmd::Edit>, L<App::karr::Cmd::Delete>,
L<App::karr::Cmd::Archive>, L<App::karr::Cmd::Handoff>

=head2 run_batch

Runs one callback per id and keeps going when an id fails, so that a bad id in
the middle of the list cannot skip the ids after it. Returns the collected
per-id results and the number of failures.

    my ( $results, $failed ) = $self->run_batch( \@ids, sub {
        my ($id) = @_;
        ...
        return { id => $id, title => $title };
    } );

Whatever the callback returns is appended to the results; a callback that dies
contributes C<< { id => $id, error => $message } >> instead and the message is
also warned to STDERR unless C<--json> is in force. The STDERR text carries any
suggestion line the failure came with (L<App::karr::Error/command_hint>); the
C<error> field stays the single line it has always been. Usage errors are
re-thrown rather than collected: they condemn the whole invocation, not one id.

=head2 report_batch_failure

    $self->report_batch_failure( $failed, scalar @ids );

Ends a batch that had failures with exit code 1 and a one-line summary, after
the ids that did succeed have been committed. A no-op when nothing failed.

=head2 no_change

    return $self->no_change if $task->status eq $wanted;

The value a L</update_task_guarded> callback returns to say that this revision
of the task needs no write: the compare-and-swap write, the C<updated> bump
that comes with it and the activity-log entry are all skipped, and the task is
returned unwritten. Any other return value -- including none -- writes as
before, so a callback that does not know about this method is unaffected.

Deciding it inside the callback rather than on a read taken beforehand is the
point (tickets #44, #46, #56): "nothing to change" is a statement about a
revision, and the revision it is made about is the one that would have been
written.

=head2 task_not_found

    die $self->task_not_found($id);

The one message every command on the mutation path raises when an id names no
card: the id as the caller gave it, and C<karr list --compact> on its own last
line as the way to see the ids that do exist. Shared so that C<move>, C<edit>,
C<delete>, C<archive> and C<handoff> -- which reach it through
L</update_task_guarded>, L</delete_task_guarded> and the unguarded pre-reads in
L<App::karr::Cmd::Archive> and L<App::karr::Cmd::Delete> -- spell it one way
(ticket k264, the shape L<App::karr::Cmd::Needs> got in k263).

=head2 update_task_guarded

Reads the task, runs the callback against it, and writes it back only if the
task ref is still exactly where it was when it was read. If another agent got



( run in 3.396 seconds using v1.01-cache-2.11-cpan-85d3896f969 )