Group
Extension

Test2-Harness/lib/App/Yath/Renderer/Notify.pm

package App::Yath::Renderer::Notify;
use strict;
use warnings;

our $VERSION = '2.000006'; # TRIAL

use Test2::Harness::Util::JSON qw/encode_json/;
use Test2::Harness::Util qw/mod2file/;

use Sys::Hostname qw/hostname/;

use Carp qw/croak confess/;

use parent 'App::Yath::Renderer';
use Test2::Harness::Util::HashBase qw{
    <final
    <tries
    <problems
    <problem_cids
    +text_mod
    +text_mod_handles_events
    +text_mod_fail
};

# Notifications only apply to commands which build a run.
sub applicable {
    my ($option, $options) = @_;

    return 1 if $options->have_group('run');
    return 0;
}

use Getopt::Yath;
option_group {prefix => 'notify', group => 'notify', category => "Notification Options", applicable => \&applicable} => sub {
    option slack => (
        type => 'List',
        description => "Send results to a slack channel and/or user",
        long_examples  => [" '#foo'", " '\@bar'"],
    );

    option slack_fail => (
        type => 'List',
        description => "Send failing results to a slack channel and/or user",
        long_examples => [" '#foo'", " '\@bar'"],
    );

    option slack_url => (
        type => 'Scalar',
        description => "Specify an API endpoint for slack webhook integrations",
        long_examples  => [" https://hooks.slack.com/..."],
    );

    option slack_owner => (
        type => 'Bool',
        description => "Send slack notifications to the slack channels/users listed in test meta-data when tests fail.",
        default => sub {
            my ($opt, $settings) = @_;
            return 1 if @{$settings->notify->slack // []};
            return 0;
        },
    );

    option no_batch_slack => (
        type => 'Bool',
        default => 0,
        description => 'Usually owner failures are sent as a single batch at the end of testing. Toggle this to send failures as they happen.',
    );

    option email_from => (
        type          => 'Scalar',
        long_examples => [' foo@example.com'],
        description   => "If any email is sent, this is who it will be from",
    );

    option email => (
        type => 'List',
        long_examples => [' foo@example.com'],
        description => "Email the test results to the specified email address(es)",
    );

    option email_fail => (
        type => 'List',
        long_examples => [' foo@example.com'],
        description => "Email failing results to the specified email address(es)",
    );

    option email_owner => (
        type => 'Bool',
        description => 'Email the owner of broken tests files upon failure. Add `# HARNESS-META-OWNER foo@example.com` to the top of a test file to give it an owner',
        default => sub {
            my ($opt, $settings) = @_;
            return 1 if @{$settings->notify->email};
            return 0;
        },
    );

    option no_batch_email => (
        type => 'Bool',
        default => 0,
        description => 'Usually owner failures are sent as a single batch at the end of testing. Toggle this to send failures as they happen.',
    );

    option text => (
        type => 'Scalar',
        alt => ['message', 'msg'],
        description => "Add a custom text snippet to email/slack notifications",
    );

    option text_module => (
        type => 'Scalar',
        alt => ['message-module'],
        description => "Use the specified module to generate messages for emails and/or slack.",
    );

    option_post_process sub {
        my ($options, $state) = @_;

        my $settings = $state->{settings};

        # Should we use email?
        if (@{$settings->notify->email} || @{$settings->notify->email_fail} || $settings->notify->email_owner) {
            die "--email-from must be specified in order to send email" unless $settings->notify->email_from;
            # Do we have Email::Stuffer?
            eval { require Email::Stuffer; 1 } or die "Cannot use send email without Email::Stuffer, which is not installed.\n";
        }

        my $use_slack = grep { $settings->notify->$_ } qw/slack_url slack_owner/;
        $use_slack ||= grep { @{$settings->notify->$_} } qw/slack slack_fail/;
        if ($use_slack) {
            die "slack url must be provided in order to use slack" unless $settings->notify->slack_url;

            eval { require HTTP::Tiny; 1 } or die "Cannot use slack without HTTP::Tiny which is not installed.\n";

            die "HTTP::Tiny reports that it does not support SSL, cannot use slack without ssl."
                unless HTTP::Tiny::can_ssl();
        }
    };
};

sub text_mod {
    my $self = shift;
    my ($settings) = @_;

    croak 'settings is a required argument' unless $settings;

    return $self->{+TEXT_MOD} if exists $self->{+TEXT_MOD};

    if (my $tm = $settings->notify->text_module) {
        my $file = mod2file($tm);
        if (eval { require $file; 1 }) {
            my $inst = $tm->can('new') ? $tm->new() : $tm;
            $self->{+TEXT_MOD_HANDLES_EVENTS} = $inst->can('handle_event') ? 1 : 0;
            return $self->{+TEXT_MOD} = $inst;
        }
        else {
            my $err = $@;
            warn "Cannot use module '$tm' for notification text generation: $err";
            chomp($self->{+TEXT_MOD_FAIL} = $err);
        }
    }

    $self->{+TEXT_MOD_HANDLES_EVENTS} = 0;
    return $self->{+TEXT_MOD} = undef;
}

sub render_event {
    my $self = shift;
    my ($e) = @_;

    my $f = $e->facet_data;

    $self->record_problem($f);

    my $settings = $self->{+SETTINGS};
    my $tm = $self->text_mod($settings);
    if ($tm && $self->{+TEXT_MOD_HANDLES_EVENTS}) {
        $tm->handle_event($e, $f, settings => $settings, notify => $self);
    }

    return $self->handle_job_end($e, $f, $settings) if $f->{harness_job_end};
    return $self->handle_final($e, $f, $settings) if $f->{harness_final};

    return;
}

sub record_problem {
    my $self = shift;
    my ($f) = @_;

    return unless $self->has_fail_or_error($f);

    my $job_id  = $f->{harness}->{job_id};
    my $job_try = $f->{harness}->{job_try} // 0;

    push @{$self->{+PROBLEMS}->{$job_id}->{$job_try}} => $self->prune_subtests($f);
}

sub has_fail_or_error {
    my $self = shift;
    my ($f, %params) = @_;

    return 0 if $f->{trace}->{nested} && !$params{allow_nested};
    return 0 if $f->{amnesty} && @{$f->{amnesty}};

    my $out = 0;

    my $cid = $f->{trace}->{cid};
    $out = 1 if $cid && $self->{+PROBLEM_CIDS}->{$cid} && $f->{info} && @{$f->{info}};
    $out = 1 if $f->{errors} && @{$f->{errors}};
    $out = 1 if $f->{assert} && !$f->{assert}->{pass};

    $self->{+PROBLEM_CIDS}->{$cid} = 1 if $cid && $out;

    return $out;
}

sub prune_subtests {
    my $self = shift;
    my ($f) = @_;

    my $p = $f->{parent}   // return $f;
    my $c = $p->{children} // return $f;

    return $f unless @$c;

    my $out = {};
    $out->{$_} = $f->{$_} for grep { $f->{$_} } qw/assert about trace errors info harness control/;
    $out->{parent} = {%$p, children => [map { $self->prune_subtests($_) } grep { $self->has_fail_or_error($_, allow_nested => 1) } @$c]};

    return $out;
}

sub handle_final {
    my $self = shift;
    my ($e, $f, $settings) = @_;

    $self->{+FINAL} = $e;
}

sub handle_job_end {
    my $self = shift;
    my ($e, $f, $settings) = @_;

    return unless $f->{harness_job_end}->{fail};

    my $job_id = $f->{harness}->{job_id};

    if ($f->{harness_job_end}->{retry}) {
        $self->{+TRIES}->{$job_id}++;
        return;
    }

    my @args = ($e, $f, $self->{+TRIES}->{$job_id}, $settings);

    $self->send_job_notification_slack(@args);
    $self->send_job_notification_email(@args);
}

sub send_job_notification_slack {
    my $self = shift;

    my ($e, $f, $tries, $settings) = @_;

    return unless $settings->notify->no_batch_slack;

    my $tf = Test2::Harness::TestFile->new(file => $f->{harness_job_end}->{abs_file});

    my @slack;
    push @slack => $tf->meta('slack') if $settings->notify->slack_owner;
    push @slack => @{$settings->notify->slack_fail};

    return unless @slack;

    my $text = $self->gen_text(scope => 'job', service => 'slack', settings => $settings, file => $tf, tries => $tries);

    $self->_send_slack($text, $settings, @slack);
}

sub gen_slack_job_text {
    my $self = shift;
    my %params = @_;

    my $settings = $params{settings} // croak "'settings' is required";
    my $tf       = $params{file}     // croak "'file' is required";
    my $tries    = $params{tries}    // 0;

    my $host = hostname();
    my $file = $tf->relative;

    return join "\n\n" => grep { $_ }
        $settings->notify->text,
        "Failed test on $host: '$file'.",
        $tries ? ("Test was run " . (1 + $tries) . " time(s).") : (),
        join "\n" => map {"> <$_|$_>"} @{$settings->run->links};
}

sub _send_slack {
    my $self = shift;
    my ($text, $settings, @to) = @_;

    require HTTP::Tiny;
    my $ht = HTTP::Tiny->new();

    for my $dest (@to) {
        my $r = $ht->post(
            $settings->notify->slack_url,
            {
                headers => {'content-type' => 'application/json'},
                content => encode_json({channel => $dest, text => $text}),
            },
        );
        warn "Failed to send slack message to '$dest'" unless $r->{success};
    }
}

sub send_job_notification_email {
    my $self = shift;

    my ($e, $f, $tries, $settings) = @_;

    return unless $settings->notify->no_batch_email;

    my $tf = Test2::Harness::TestFile->new(file => $f->{harness_job_end}->{abs_file});

    my @to;
    push @to => $tf->meta('owner') if $settings->notify->email_owner;
    push @to => @{$settings->notify->email_fail};
    return unless @to;

    my $text = $self->gen_text(scope => 'job', service => 'email', settings => $settings, file => $tf, tries => $tries);
    my $subject = "Failed test on " . hostname() . ": '" . $tf->relative . "'.";

    $self->_send_email($subject, $text, $settings, @to);
}

sub gen_email_job_text {
    my $self = shift;
    my %params = @_;

    my $settings = $params{settings} // croak "'settings' is required";
    my $tf       = $params{file}     // croak "'file' is required";
    my $tries    = $params{tries}    // 0;

    my $host = hostname();
    my $file = $tf->relative;

    return join "\n\n" => grep { $_ }
        $settings->notify->text,
        "Failed test on $host: '$file'.",
        $tries ? ("Test was run " . (1 + $tries) . " time(s).") : (),
        join "\n" => @{$settings->run->links};
}

sub _send_email {
    my $self = shift;
    my ($subject, $text, $settings, @to) = @_;

    my $mail = Email::Stuffer->to(@to);
    $mail->from($settings->notify->email_from);
    $mail->subject($subject);

    my $rtype = ref($text) // '';

    if (!$rtype) {
        $mail->text_body($text);
    }
    elsif ($rtype eq 'HASH') {
        $mail->text_body($text->{text}) if $text->{text};
        $mail->html_body($text->{html}) if $text->{html};
    }
    else {
        warn "Invalid text type: '$rtype'";
    }

    eval { $mail->send_or_die; 1 } or warn $@;
}

sub finish {
    my $self = shift;
    my ($auditor) = @_;

    my $settings = $self->{+SETTINGS};

    my $e = $self->{+FINAL} or return;
    my $f = $e->facet_data or return;
    my $final = $f->{harness_final} or return;

    $self->send_run_notification_slack($final, $settings);
    $self->send_run_notification_email($final, $settings);
}

sub send_run_notification_slack {
    my $self = shift;
    my ($final, $settings) = @_;

    return if $settings->notify->no_batch_slack;

    my @to = @{$settings->notify->slack};
    push @to => @{$settings->notify->slack_fail} unless $final->{pass};

    my $files = "";
    if ($final->{failed}) {
        for my $set (@{$final->{failed}}) {
            my $file = $set->[1];

            $files = $files ? "$files\n$file" : $file;

            next unless $settings->notify->slack_owner;
            my $tf = Test2::Harness::TestFile->new(file => $file);
            push @to => $tf->meta('slack');
        }
    }

    return unless @to;

    my $text = $self->gen_text(
        scope    => 'run',
        service  => 'slack',
        settings => $settings,
        final    => $final,
        files    => $files,
    );

    $self->_send_slack($text, $settings, @to);
}

sub gen_slack_run_text {
    my $self = shift;
    my %params = @_;

    my $settings = $params{settings} // croak "'settings' is required";
    my $final    = $params{final}    // croak "'final' is required";
    my $files    = $params{files}    // '';

    my $host = hostname();

    return join "\n\n" => grep { $_ } (
        $settings->notify->text,
        ($final->{pass} ? "Tests passed on $host" : "Tests failed on $host"),
        ($files ? $files : ()),
        join("\n" => map {"> <$_|$_>"} @{$settings->run->links}),
    );
}

sub send_run_notification_email {
    my $self = shift;
    my ($final, $settings) = @_;

    return if $settings->notify->no_batch_email;

    my @to = @{$settings->notify->email};
    push @to => @{$settings->notify->email_fail} unless $final->{pass};

    my $files = "";
    if ($final->{failed}) {
        for my $set (@{$final->{failed}}) {
            my $file = $set->[1];

            $files = $files ? "$files\n$file" : $file;

            next unless $settings->notify->email_owner;
            my $tf = Test2::Harness::TestFile->new(file => $file);
            push @to => $tf->meta('owner');
        }
    }

    return unless @to;

    my $subject = $self->gen_text(
        scope    => 'run',
        service  => 'email_subject',
        settings => $settings,
        final    => $final,
        files    => $files,
    );

    my $text = $self->gen_text(
        scope    => 'run',
        service  => 'email',
        settings => $settings,
        final    => $final,
        files    => $files,
        subject  => $subject,
    );

    $self->_send_email($subject, $text, $settings, @to);
}

sub gen_email_subject_run_text {
    my $self = shift;
    my %params = @_;

    my $final = $params{final} // croak "'final' is required";
    my $host  = hostname();

    return $final->{pass} ? "Tests passed on $host" : "Tests failed on $host";
}

sub gen_email_run_text {
    my $self = shift;
    my %params = @_;

    my $subject  = $params{subject}  // $self->gen_text(%params, service => 'email_subject');
    my $settings = $params{settings} // croak "'settings' is required";
    my $final    = $params{final}    // croak "'final' is required";
    my $files    = $params{files}    // '';

    return join "\n\n" => grep { $_ } (
        $settings->notify->text,
        $subject,
        ($files ? $files : ()),
        join("\n" => @{$settings->run->links}),
    );
}

sub gen_text {
    my $self   = shift;
    my %params = @_;

    my $scope    = $params{scope}    or croak "'scope' is required";
    my $service  = $params{service}  or croak "'service' is required";
    my $settings = $params{settings} or croak "'settings' is required";

    my $meth = "gen_${service}_${scope}_text";

    if (my $tm = $self->text_mod($settings)) {
        return $tm->$meth(%params, notify => $self)
            if $tm->can($meth);
    }

    if ($self->can($meth)) {
        my $text = $self->$meth(%params);

        my $mod = $settings->notify->text_module;
        $text = <<"        EOT" if $self->{+TEXT_MOD_FAIL} && $service !~ m/subject/i;
*******************************************************************************
There was an error loading the text generation module '$mod'.
Because of this error the default notification text has been used.

The error encountered was:
$self->{+TEXT_MOD_FAIL}
*******************************************************************************

$text
        EOT

        return $text;
    }

    confess "No notification text method '$meth'";
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

App::Yath::Renderer::Notify - Renderer to send email and/or slack notifications

=head1 DESCRIPTION

This renderer is used for sending email and/or slack notifications from yath.

=head1 SYNOPSIS

=head2 IN A TEST

    #!/usr/bin/perl
    use Test2::V0;
    # HARNESS-META owner author@example.com
    # HARNESS-META slack #slack_channel
    # HARNESS-META slack #slack_user

You can use the C<# HARNESS-META owner EMAIL_ADDRESS> to specify an "owner"
email address. You can use the C<# HARNESS-META slack USER/CHANNEL> to specify
a slack user or channel that owns the test.

=head2 RUNNING WITH NOTIFICATIONS ENABLED

    $ yath test --renderer Notify ...

Also of note, most of the time you can just specify the notification options
you want and the renderer will load as needed.

=head3 EMAIL

    $ yath test --notify-email-owner --notify-email-from user@example.com --notify-email-fail fixer@example.com

=head3 SLACK

A slack hooks url is always needed for slack to work.

    $ yath test --notify-slack-url https://hooks.slack.com/... --notify-slack-fail '#foo' --notify-slack-owner

=head1 PROVIDED OPTIONS

=head2 Notification Options

=over 4

=item --notify-email foo@example.com

=item --no-notify-email

Email the test results to the specified email address(es)

Note: Can be specified multiple times


=item --notify-email-fail foo@example.com

=item --no-notify-email-fail

Email failing results to the specified email address(es)

Note: Can be specified multiple times


=item --notify-email-from foo@example.com

=item --no-notify-email-from

If any email is sent, this is who it will be from


=item --notify-email-owner

=item --no-notify-email-owner

Email the owner of broken tests files upon failure. Add `# HARNESS-META-OWNER foo@example.com` to the top of a test file to give it an owner


=item --notify-no-batch-email

=item --no-notify-no-batch-email

Usually owner failures are sent as a single batch at the end of testing. Toggle this to send failures as they happen.


=item --notify-no-batch-slack

=item --no-notify-no-batch-slack

Usually owner failures are sent as a single batch at the end of testing. Toggle this to send failures as they happen.


=item --notify-slack '#foo'

=item --notify-slack '@bar'

=item --no-notify-slack

Send results to a slack channel and/or user

Note: Can be specified multiple times


=item --notify-slack-fail '#foo'

=item --notify-slack-fail '@bar'

=item --no-notify-slack-fail

Send failing results to a slack channel and/or user

Note: Can be specified multiple times


=item --notify-slack-owner

=item --no-notify-slack-owner

Send slack notifications to the slack channels/users listed in test meta-data when tests fail.


=item --notify-slack-url https://hooks.slack.com/...

=item --no-notify-slack-url

Specify an API endpoint for slack webhook integrations


=item --notify-msg ARG

=item --notify-msg=ARG

=item --notify-text ARG

=item --notify-text=ARG

=item --notify-message ARG

=item --notify-message=ARG

=item --no-notify-text

Add a custom text snippet to email/slack notifications


=item --notify-text-module ARG

=item --notify-text-module=ARG

=item --notify-message-module ARG

=item --notify-message-module=ARG

=item --no-notify-text-module

Use the specified module to generate messages for emails and/or slack.


=back


=head1 SOURCE

The source code repository for Test2-Harness can be found at
L<http://github.com/Test-More/Test2-Harness/>.

=head1 MAINTAINERS

=over 4

=item Chad Granum E<lt>exodist@cpan.orgE<gt>

=back

=head1 AUTHORS

=over 4

=item Chad Granum E<lt>exodist@cpan.orgE<gt>

=back

=head1 COPYRIGHT

Copyright Chad Granum E<lt>exodist7@gmail.comE<gt>.

This program is free software; you can redistribute it and/or
modify it under the same terms as Perl itself.

See L<http://dev.perl.org/licenses/>

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.