File Coverage

blib/lib/App/cpanminus/reporter.pm
Criterion Covered Total %
statement 243 307 79.1
branch 110 192 57.2
condition 47 92 51.0
subroutine 46 52 88.4
pod 0 23 0.0
total 446 666 66.9


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