File Coverage

blib/lib/App/cpanminus/reporter.pm
Criterion Covered Total %
statement 245 309 79.2
branch 109 190 57.3
condition 46 86 53.4
subroutine 46 52 88.4
pod 0 23 0.0
total 446 660 67.5


line stmt bran cond sub pod time code
1             package App::cpanminus::reporter;
2              
3 21     21   1527980 use warnings;
  21         226  
  21         720  
4 21     21   128 use strict;
  21         41  
  21         959  
5              
6             our $VERSION = '0.21';
7              
8 21     21   190 use Carp ();
  21         56  
  21         686  
9 21     21   169 use File::Spec 3.19;
  21         818  
  21         584  
10 21     21   9526 use File::HomeDir::Tiny ();
  21         6380  
  21         563  
11 21     21   11082 use Test::Reporter 1.54;
  21         736061  
  21         901  
12 21     21   13212 use CPAN::Testers::Common::Client 0.13;
  21         4308699  
  21         886  
13 21     21   242 use CPAN::Testers::Common::Client::Config;
  21         58  
  21         489  
14 21     21   11535 use Parse::CPAN::Meta;
  21         35458  
  21         1265  
15 21     21   14750 use CPAN::Meta::Converter;
  21         446611  
  21         1053  
16 21     21   12118 use Try::Tiny;
  21         45020  
  21         1364  
17 21     21   12547 use URI;
  21         102197  
  21         755  
18 21     21   10245 use Metabase::Resource;
  21         27946  
  21         813  
19 21     21   202 use Capture::Tiny qw(capture);
  21         77  
  21         1512  
20 21     21   10204 use IO::Prompt::Tiny ();
  21         11914  
  21         42482  
21              
22             sub new {
23 21     21 0 3396 my ($class, %params) = @_;
24 21         86 my $self = bless {}, $class;
25              
26             $self->config(
27             CPAN::Testers::Common::Client::Config->new(
28 0     0   0 prompt => sub { local %ENV; IO::Prompt::Tiny::prompt(@_) },
  0         0  
29             )
30 21         399 );
31              
32 21 50       121 if ($params{cpanm}) {
33 0         0 my $cpanm = $self->_cpanm( $params{cpanm} );
34 0         0 $params{only} =~ s/-\d+(\.\d+)*$//; # strip version from cpanm's "only" data
35              
36             # FIXME: cpanm doesn't provide an accessor here, so
37             # we break encapsulation in order to make sure we
38             # always have the right paths.
39 0         0 $params{build_dir} = $cpanm->{home};
40 0         0 $params{build_logfile} = $cpanm->{log};
41             }
42              
43             $self->build_dir(
44             $params{build_dir}
45 21   33     690 || File::Spec->catdir( File::HomeDir::Tiny::home(), '.cpanm' )
46             );
47              
48             $self->build_logfile(
49             $params{build_logfile}
50 21   66     173 || File::Spec->catfile( $self->build_dir, 'build.log' )
51             );
52              
53 21   50     211 $self->max_age($params{max_age} || 30);
54              
55 21         83 foreach my $option ( qw(quiet verbose force exclude only dry-run skip-history ignore-versions all) ) {
56 189         324 my $method = $option;
57 189         405 $method =~ s/\-/_/g;
58 189 100       597 $self->$method( $params{$option} ) if exists $params{$option};
59             }
60              
61 21         169 return $self;
62             }
63              
64 0     0 0 0 sub setup { shift->config->setup }
65              
66             ## basic accessors ##
67              
68             sub author {
69 78     78 0 236 my ($self, $author) = @_;
70 78 100       235 $self->{_author} = $author if $author;
71 78         262 return $self->{_author};
72             }
73              
74             sub distfile {
75 76     76 0 1657 my ($self, $distfile) = @_;
76 76 100       278 $self->{_distfile} = $distfile if $distfile;
77 76         201 return $self->{_distfile};
78             }
79              
80             sub config {
81 28     28 0 1844 my ($self, $config) = @_;
82 28 100       176 $self->{_config} = $config if $config;
83 28         106 return $self->{_config};
84             }
85              
86             sub verbose {
87 257     257 0 604 my ($self, $verbose) = @_;
88 257 100       573 $self->{_verbose} = $verbose if $verbose;
89 257         792 return $self->{_verbose};
90             }
91              
92             sub all {
93 18     18 0 69 my ($self, $all) = @_;
94 18 50       54 $self->{_all} = $all if $all;
95 18         117 return $self->{_all};
96             }
97              
98             sub max_age {
99 39     39 0 101 my ($self, $max_age) = @_;
100 39 100       124 $self->{_max_age} = $max_age if $max_age;
101 39         78 return $self->{_max_age};
102             }
103              
104             sub force {
105 41     41 0 116 my ($self, $force) = @_;
106 41 100       120 $self->{_force} = $force if $force;
107 41         160 return $self->{_force};
108             }
109              
110             sub ignore_versions {
111 88     88 0 247 my ($self, $ignore_versions) = @_;
112 88 100       278 $self->{_ignore_versions} = $ignore_versions if $ignore_versions;
113 88         332 return $self->{_ignore_versions};
114             }
115              
116             sub quiet {
117 11     11 0 34 my ($self, $quiet) = @_;
118 11 100       27 if ($quiet) {
119 3         17 $self->verbose(0);
120 3         13 $self->{_quiet} = 1;
121             }
122 11         44 return $self->{_quiet};
123             }
124              
125             sub dry_run {
126 2     2 0 21 my ($self, $dry_run) = @_;
127 2 50       12 $self->{_dry_run} = $dry_run if $dry_run;
128 2         15 $self->{_dry_run};
129             }
130              
131             sub skip_history {
132 6     6 0 36 my ($self, $skip) = @_;
133 6 100       19 $self->{_skip_history} = $skip if $skip;
134 6         28 $self->{_skip_history};
135             }
136              
137             sub only {
138 74     74 0 650 my ($self, $only) = @_;
139 74 100       187 if ($only) {
140 1         5 $only =~ s/::/-/g;
141 1         10 my @modules = split /\s*,\s*/, $only;
142 1         5 foreach (@modules) { $_ =~ s/(\S+)-[\d.]+$/$1/ };
  3         10  
143              
144 1         15 $self->{_only} = { map { $_ => 0 } @modules };
  3         11  
145             }
146 74         396 return $self->{_only};
147             }
148              
149             sub exclude {
150 74     74 0 199 my ($self, $exclude) = @_;
151 74 100       193 if ($exclude) {
152 1         6 $exclude =~ s/::/-/g;
153 1         9 my @modules = split /\s*,\s*/, $exclude;
154 1         4 foreach (@modules) { $_ =~ s/(\S+)-[\d.]+$/$1/ };
  3         10  
155              
156 1         3 $self->{_exclude} = { map { $_ => 0 } @modules };
  3         12  
157             }
158 74         395 return $self->{_exclude};
159             }
160              
161             sub build_dir {
162 26     26 0 1678 my ($self, $dir) = @_;
163 26 100       135 $self->{_build_dir} = $dir if $dir;
164 26         120 return $self->{_build_dir};
165             }
166              
167             sub build_logfile {
168 40     40 0 113 my ($self, $file) = @_;
169 40 100       125 $self->{_build_logfile} = $file if $file;
170 40         103 return $self->{_build_logfile};
171             }
172              
173             sub _cpanm {
174 0     0   0 my ($self, $cpanm) = @_;
175 0 0       0 $self->{_cpanm_object} = $cpanm if $cpanm;
176 0         0 return $self->{_cpanm_object};
177             }
178              
179             sub _check_cpantesters_config_data {
180 0     0   0 my $self = shift;
181 0         0 my $config = $self->config;
182 0         0 my $filename = $config->get_config_filename;
183              
184 0 0       0 if (-e $filename) {
185 0 0       0 if (!$config->read) {
186 0         0 print "Error reading CPAN Testers configuration file '$filename'. Aborting.";
187 0         0 return;
188             }
189             }
190             else {
191 0         0 my $answer = IO::Prompt::Tiny::prompt("CPAN Testers configuration file '$filename' not found. Would you like to set it up now? (y/n)", 'y');
192              
193 0 0       0 if ( $answer =~ /^y/i ) {
194 0         0 $config->setup;
195             }
196             else {
197 0         0 print "The CPAN Testers configuration file is required. Aborting.\n";
198 0         0 return;
199             }
200             }
201 0         0 return 1;
202             }
203              
204             # Returns 1 if log is fresh enough, 0 if it is too old.
205             sub _check_build_log {
206 18     18   49 my ($self, $build_logfile) = @_;
207              
208 18         50 my $max_age = $self->max_age;
209              
210             # as a safety mechanism, we only let people parse build.log files
211             # if they were generated up to 30 minutes (1800 seconds) ago,
212             # unless the user asks us to --force it.
213 18         454 my $mtime = (stat $build_logfile)[9];
214 18         145 my $age_in_minutes = int((time - $mtime) / 60);
215 18 0 33     71 if ( !$self->force && $mtime && $age_in_minutes > $max_age ) {
      33        
216 0 0       0 if($self->all) {
217 0         0 print "Skipping $build_logfile, too old (modified $age_in_minutes minutes ago > $max_age)."
218             } else {
219 0         0 print <<"EOMESSAGE";
220             $build_logfile is too old (created $age_in_minutes minutes ago).
221              
222             As a standalone tool, it is important that you run cpanm-reporter as
223             soon as you finish cpanm, otherwise your system data may have changed,
224             from new libraries to a completely different perl binary.
225              
226             Because of that, this app will *NOT* parse build.log files which are
227             too old (by default: which are last modified more than 30 minutes ago).
228              
229             You can override this behaviour by touching the file, passing
230             --max-age option or --force flag, but please take good care to avoid
231             sending bogus reports.
232             EOMESSAGE
233             }
234 0         0 return;
235             }
236 18         81 return 1;
237             }
238              
239             sub _get_logfiles {
240 18     18   59 my ($self) = @_;
241 18         37 my @files;
242 18 50       84 if ($self->all) {
243 0         0 my $workdir = File::Spec->catdir($self->build_dir, 'work');
244 0 0       0 if (-e $workdir) {
245 0 0       0 opendir my $dh, $workdir or return ();
246 0 0       0 my @children = grep { $_ ne '.' && $_ ne '..' } readdir $dh;
  0         0  
247 0         0 closedir $dh;
248 0         0 foreach my $child (@children) {
249 0         0 my $logfile = File::Spec->catfile($workdir, $child, 'build.log');
250 0 0 0     0 if (-e $logfile && !-d _) {
251 0         0 push @files, $logfile;
252             }
253             }
254             }
255             else {
256 0         0 print <<"EOMSG";
257             Can not find cpanm work directory (tried $workdir).
258             Please specify top cpanm dir as --build-dir, or do not
259             specify --build-dir if it is in ~/.cpanm.
260             EOMSG
261             }
262             }
263             else {
264 18         62 push @files, $self->build_logfile;
265             }
266 18         70 return @files;
267             }
268              
269             sub run {
270 18     18 0 1344 my $self = shift;
271 18 50       64 return unless $self->_check_cpantesters_config_data;
272 18         153 foreach my $logfile ($self->_get_logfiles) {
273 18         72 $self->process_logfile($logfile);
274             }
275 18         1461 return;
276             }
277              
278             sub process_logfile {
279 18     18 0 53 my ($self, $logfile) = @_;
280              
281 18 50       64 return unless $self->_check_build_log($logfile);
282              
283 18 50       833 open my $fh, '<', $logfile
284             or Carp::croak "error opening build log file '$logfile' for reading: $!";
285              
286 18         1013 my $header = <$fh>;
287 18 50       230 if ($header =~ /^cpanm \(App::cpanminus\) (\d+\.\d+) on perl (\d+\.\d+)/) {
288 18         135 $self->{_cpanminus_version} = $1;
289 18         134 $self->{_perl_version} = $2;
290             }
291             else {
292 0         0 Carp::croak(
293             'Unable to find cpanminus/perl versions on build.log. '
294             . 'Please update App::cpanminus. If you think this is a mistake, '
295             . 'please send us a bug report with your version of App::cpanminus, '
296             . 'App::cpanminus::reporter, perl -V and your failing build.log file.'
297             );
298             }
299              
300 18         45 my $found = 0;
301 18         37 my $parser;
302              
303             # we could go over 100 levels deep on the dependency track
304 21     21   216 no warnings 'recursion';
  21         78  
  21         49285  
305             $parser = sub {
306 90     90   206 my ($dist, $resource) = @_;
307 90 100       531 (my $dist_vstring = $dist) =~ s/\-(\d+(?:\.\d)+)$/-v$1/ if $dist;
308 90         194 my @test_output = ();
309 90         122 my $recording;
310 90         155 my $has_tests = 0;
311 90         146 my $found_na;
312             my $fetched;
313              
314 90         629 while (<$fh>) {
315 7398 100 100     38774 if ( /^Fetching (\S+)/ ) {
    100          
    100          
    100          
316 71 50       215 next if /CHECKSUMS$/;
317 71         239 $fetched = $1;
318 71 100       195 $resource = $fetched unless $resource;
319             }
320             elsif ( /^Entering (\S+)/ ) {
321 72         207 my $dep = $1;
322 72         152 $found = 1;
323 72 50 66     285 if ($recording && $recording eq 'test') {
324 0         0 Carp::croak 'Parsing error. This should not happen. Please send us a report!';
325             }
326             else {
327 72 50 0     254 print "entering $dep, " . ($fetched || '(local)') . "\n" if $self->verbose;
328 72         486 $parser->($dep, $fetched);
329 72 50 0     226 print "left $dep, " . ($fetched || '(local)') . "\n" if $self->verbose;
330 72         671 next;
331             }
332             }
333             elsif ( /^Running (?:Build|Makefile)\.PL/ ) {
334 73         152 $recording = 'configure';
335             }
336             elsif ( $dist and /^Building .*(?:$dist|$dist_vstring)/) {
337 68 50       252 print "recording $dist\n" if $self->verbose;
338 68 100       365 $has_tests = 1 if /and testing/;
339             # if we got here, we need to flush the test output
340             # (so far filled with 'configure' output) and start
341             # recording the actual tests.
342 68         330 @test_output = ();
343 68         138 $recording = 'test';
344             }
345              
346 7326 100       16714 push @test_output, $_ if $recording;
347              
348 7326         9445 my $result;
349 7326 100       11964 if ($recording) {
350 5856 100 100     42829 if ( /^Result: (PASS|NA|FAIL|UNKNOWN|NOTESTS)/
    100 100        
    100 100        
      66        
      66        
351             || ($recording eq 'test' && /^-> (FAIL|OK)/)
352             ) {
353 69         210 $result = $1;
354 69 50 66     525 if ($result eq 'FAIL' && $recording eq 'configure') {
    100 66        
    100 100        
    50          
355 0         0 $result = 'NA';
356             }
357             elsif ($result eq 'FAIL' && @test_output > 1 && $test_output[-2] =~ /make.*?[1-9]/) {
358             # [dn]make error returning non-zero status should be graded UNKNOWN
359 1         4 $result = 'UNKNOWN';
360             }
361             elsif ($result eq 'OK') {
362 4 100       17 $result = $has_tests ? 'PASS' : 'UNKNOWN';
363             }
364             elsif ($result eq 'NOTESTS') {
365 0         0 $result = 'UNKNOWN';
366             }
367             }
368             elsif ( $recording eq 'configure' && /^-> N\/A/ ) {
369 2         5 $found_na = 1;
370             }
371             elsif ( $recording eq 'configure'
372             # https://github.com/miyagawa/cpanminus/blob/devel/lib/App/cpanminus/script.pm#L2269
373             && ( /Configure failed for (?:$dist|$dist_vstring)/
374             || /proper Makefile.PL\/Build.PL/
375             || /configure the distribution/
376             )
377             ) {
378 2 50       9 $result = $found_na ? 'NA' : 'UNKNOWN';
379             }
380             }
381 7326 100       22634 if ($result) {
382 71         150 my $dist_without_version = $dist;
383 71         470 $dist_without_version =~ s/(\S+)-[\d.]+$/$1/;
384              
385 71 100 33     439 if (@test_output <= 2) {
    50 33        
    50 66        
    50 66        
    100          
386 1         70 print "No test output found for '$dist'. Skipping...\n"
387             . "To send test reports, please make sure *NOT* to pass '-v' to cpanm or your build.log will contain no output to send.\n";
388             }
389             elsif (!$resource) {
390 0         0 print "Skipping report for local installation of '$dist'.\n";
391             }
392             elsif ( defined $self->exclude && exists $self->exclude->{$dist_without_version} ) {
393 0 0       0 print "Skipping $dist as it's in the 'exclude' list...\n" if $self->verbose;
394             }
395             elsif ( defined $self->only && !exists $self->only->{$dist_without_version} ) {
396 0 0       0 print "Skipping $dist as it isn't in the 'only' list...\n" if $self->verbose;
397             }
398             elsif ( !$self->ignore_versions && defined $self->{_perl_version} && ( $self->{_perl_version} ne $] ) ) {
399 1 50       19 print "Skipping $dist as its build Perl version ($self->{_perl_version}) differs from the currently running perl ($])...\n" if $self->verbose;
400             }
401             else {
402 69         413 my $report = $self->make_report($resource, $dist, $result, @test_output);
403             }
404 71         46537 return;
405             }
406             }
407 18         150 };
408              
409 18 50       120 print "Parsing $logfile...\n" if $self->verbose;
410 18         80 $parser->();
411 18 50 66     180 print "No reports found.\n" if !$found and $self->verbose;
412 18 50       91 print "Finished.\n" if $self->verbose;
413              
414 18         253 close $fh;
415 18         154 return;
416             }
417              
418             sub get_author {
419 61     61 0 199 my ($self, $path) = @_;
420 61 100       178 if ($path->scheme eq 'file') {
421 1         63 return $self->_get_author_from_file($path);
422             }
423             else {
424 60         1000 return $self->_get_author_from_metabase($path->path);
425             }
426             }
427              
428             sub _get_author_from_file {
429 1     1   5 my ($self, $path) = @_;
430              
431 1         84 my $directories = (File::Spec->splitpath($path))[1];
432 1         104 my @path = File::Spec->splitdir($directories);
433 1 50       16 pop @path if $path[-1] eq '';
434              
435 1 50 33     88 if ( @path >= 3 # R/RJ/RJBS
      33        
      33        
436             && $path[-1] =~ /\A[A-Z\-]+\z/ # RJBS
437             && substr($path[-1], 0, 2) eq $path[-2] # RJ
438             && substr($path[-1], 0, 1) eq $path[-3] # R
439             ) {
440 1         21 return $path[-1];
441             }
442             else {
443 0 0       0 print "DEBUG: path '$path' doesn't look valid" if $self->verbose;
444 0         0 return;
445             }
446             }
447              
448             sub _get_author_from_metabase {
449 60     60   789 my ($self, $path) = @_;
450 60         90 my $metadata;
451              
452             try {
453 60     60   3184 $metadata = Metabase::Resource->new( q[cpan:///distfile/] . $path )->metadata;
454             }
455             catch {
456 0 0   0   0 print "DEBUG: $_" if $self->verbose;
457 60         496 };
458 60 50       62349 return unless $metadata;
459              
460 60         280 return $metadata->{cpan_id};
461             }
462              
463              
464             # returns false in case of error (so, skip!)
465             sub parse_uri {
466 61     61 0 6301 my ($self, $resource) = @_;
467              
468 61         274 my $uri = URI->new( $resource );
469 61         90670 my $scheme = lc $uri->scheme;
470 61         2023 my %eligible_schemes = map {$_ => 1} (qw| http https ftp cpan file |);
  305         841  
471 61 50       233 if (! $eligible_schemes{$scheme}) {
472 0 0       0 print "invalid scheme '$scheme' for resource '$resource'. Skipping...\n"
473             unless $self->quiet;
474 0         0 return;
475             }
476              
477 61         222 my $author = $self->get_author( $uri );
478              
479 61 50       183 unless (defined $author) {
480 0 0       0 print "error fetching author for resource '$resource'. Skipping...\n"
481             unless $self->quiet;
482 0         0 return;
483             }
484              
485             # the 'LOCAL' user is reserved and should never send reports.
486 61 100       180 if ($author eq 'LOCAL') {
487 1 50       4 print "'LOCAL' user is reserved. Skipping resource '$resource'\n"
488             unless $self->quiet;
489 1         16 return;
490             }
491              
492 60         261 $self->author($author);
493              
494 60         211 $self->distfile(substr("$uri", index("$uri", $author)));
495              
496 60         337 return 1;
497             }
498              
499             sub make_report {
500 4     4 0 33 my ($self, $resource, $dist, $result, @test_output) = @_;
501              
502 4 100       23 if ( index($dist, 'Local-') == 0 ) {
503 1 50       3 print "'Local::' namespace is reserved. Skipping resource '$resource'\n"
504             unless $self->quiet;
505 1         5 return;
506             }
507 3 100       12 return unless $self->parse_uri($resource);
508              
509 2         14 my $author = $self->author;
510              
511 2   100     17 my $cpanm_version = $self->{_cpanminus_version} || 'unknown cpanm version';
512 2         10 my $meta = $self->get_meta_for( $dist );
513             my $client = CPAN::Testers::Common::Client->new(
514             author => $self->author,
515             distname => $dist,
516             grade => $result,
517             via => "App::cpanminus::reporter $VERSION ($cpanm_version)",
518             test_output => join( '', @test_output ),
519             prereqs => ($meta && ref $meta) ? $meta->{prereqs} : undef,
520 2 50 33     13 );
521              
522 2 50 33     137 if (!$self->skip_history && $client->is_duplicate) {
523 0 0       0 print "($resource, $author, $dist, $result) was already sent. Skipping...\n"
524             if $self->verbose;
525 0         0 return;
526             }
527             else {
528 2 50       9 print "sending: ($resource, $author, $dist, $result)\n" unless $self->quiet;
529             }
530              
531 2         10 my $reporter = Test::Reporter->new(
532             transport => $self->config->transport_name,
533             transport_args => $self->config->transport_args,
534             grade => $client->grade,
535             distribution => $dist,
536             distfile => $self->distfile,
537             from => $self->config->email_from,
538             comments => $client->email,
539             via => $client->via,
540             );
541              
542 2 50       708903 if ($self->dry_run) {
543 0 0       0 print "not sending (dry run)\n" unless $self->quiet;
544 0         0 return;
545             }
546              
547             try {
548 2 50   2   381 $reporter->send() || die $reporter->errstr();
549             }
550             catch {
551 0 0   0   0 print "Error while sending this report, continuing with the next one ($_)...\n" unless $self->quiet;
552 0 0       0 print "DEBUG: @_" if $self->verbose;
553             } finally{
554 2 50   2   734 $client->record_history unless $self->skip_history;
555 2         59 };
556 2         225 return;
557             }
558              
559             sub get_meta_for {
560 2     2 0 5 my ($self, $dist) = @_;
561 2         9 my $distdir = File::Spec->catdir( $self->build_dir, 'latest-build', $dist );
562              
563 2         11 foreach my $meta_file ( qw( MYMETA.json MYMETA.yml META.json META.yml ) ) {
564 8         76 my $meta_path = File::Spec->catfile( $distdir, $meta_file );
565 8 50       122 if (-e $meta_path) {
566 0         0 my $meta = eval { Parse::CPAN::Meta->load_file( $meta_path ) };
  0         0  
567 0 0       0 next if $@;
568              
569 0 0 0     0 if (!$meta->{'meta-spec'} or $meta->{'meta-spec'}{version} < 2) {
570 0         0 $meta = CPAN::Meta::Converter->new( $meta )->convert( version => 2 );
571             }
572 0         0 return $meta;
573             }
574             }
575 2         10 return;
576             }
577              
578              
579             42;
580             __END__