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   1473118 use warnings;
  21         211  
  21         692  
4 21     21   144 use strict;
  21         42  
  21         854  
5              
6             our $VERSION = '0.20';
7              
8 21     21   144 use Carp ();
  21         64  
  21         652  
9 21     21   138 use File::Spec 3.19;
  21         839  
  21         587  
10 21     21   9158 use File::HomeDir::Tiny ();
  21         6197  
  21         514  
11 21     21   10651 use Test::Reporter 1.54;
  21         706881  
  21         893  
12 21     21   12477 use CPAN::Testers::Common::Client 0.13;
  21         4135936  
  21         801  
13 21     21   212 use CPAN::Testers::Common::Client::Config;
  21         55  
  21         496  
14 21     21   10609 use Parse::CPAN::Meta;
  21         32687  
  21         1176  
15 21     21   13257 use CPAN::Meta::Converter;
  21         425044  
  21         1061  
16 21     21   11101 use Try::Tiny;
  21         43080  
  21         1247  
17 21     21   16167 use URI;
  21         98795  
  21         764  
18 21     21   9367 use Metabase::Resource;
  21         26205  
  21         760  
19 21     21   174 use Capture::Tiny qw(capture);
  21         121  
  21         1396  
20 21     21   9408 use IO::Prompt::Tiny ();
  21         11394  
  21         40106  
21              
22             sub new {
23 21     21 0 3275 my ($class, %params) = @_;
24 21         81 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         356 );
31              
32 21 50       110 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     665 || File::Spec->catdir( File::HomeDir::Tiny::home(), '.cpanm' )
46             );
47              
48             $self->build_logfile(
49             $params{build_logfile}
50 21   66     163 || File::Spec->catfile( $self->build_dir, 'build.log' )
51             );
52              
53 21   50     204 $self->max_age($params{max_age} || 30);
54              
55 21         85 foreach my $option ( qw(quiet verbose force exclude only dry-run skip-history ignore-versions all) ) {
56 189         303 my $method = $option;
57 189         376 $method =~ s/\-/_/g;
58 189 100       604 $self->$method( $params{$option} ) if exists $params{$option};
59             }
60              
61 21         139 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 204 my ($self, $author) = @_;
70 78 100       202 $self->{_author} = $author if $author;
71 78         225 return $self->{_author};
72             }
73              
74             sub distfile {
75 76     76 0 1521 my ($self, $distfile) = @_;
76 76 100       248 $self->{_distfile} = $distfile if $distfile;
77 76         190 return $self->{_distfile};
78             }
79              
80             sub config {
81 28     28 0 1679 my ($self, $config) = @_;
82 28 100       146 $self->{_config} = $config if $config;
83 28         106 return $self->{_config};
84             }
85              
86             sub verbose {
87 257     257 0 556 my ($self, $verbose) = @_;
88 257 100       568 $self->{_verbose} = $verbose if $verbose;
89 257         788 return $self->{_verbose};
90             }
91              
92             sub all {
93 18     18 0 51 my ($self, $all) = @_;
94 18 50       54 $self->{_all} = $all if $all;
95 18         110 return $self->{_all};
96             }
97              
98             sub max_age {
99 39     39 0 115 my ($self, $max_age) = @_;
100 39 100       110 $self->{_max_age} = $max_age if $max_age;
101 39         79 return $self->{_max_age};
102             }
103              
104             sub force {
105 41     41 0 115 my ($self, $force) = @_;
106 41 100       122 $self->{_force} = $force if $force;
107 41         164 return $self->{_force};
108             }
109              
110             sub ignore_versions {
111 88     88 0 203 my ($self, $ignore_versions) = @_;
112 88 100       213 $self->{_ignore_versions} = $ignore_versions if $ignore_versions;
113 88         331 return $self->{_ignore_versions};
114             }
115              
116             sub quiet {
117 11     11 0 27 my ($self, $quiet) = @_;
118 11 100       30 if ($quiet) {
119 3         13 $self->verbose(0);
120 3         7 $self->{_quiet} = 1;
121             }
122 11         38 return $self->{_quiet};
123             }
124              
125             sub dry_run {
126 2     2 0 14 my ($self, $dry_run) = @_;
127 2 50       26 $self->{_dry_run} = $dry_run if $dry_run;
128 2         18 $self->{_dry_run};
129             }
130              
131             sub skip_history {
132 6     6 0 17 my ($self, $skip) = @_;
133 6 100       19 $self->{_skip_history} = $skip if $skip;
134 6         25 $self->{_skip_history};
135             }
136              
137             sub only {
138 74     74 0 180 my ($self, $only) = @_;
139 74 100       175 if ($only) {
140 1         6 $only =~ s/::/-/g;
141 1         9 my @modules = split /\s*,\s*/, $only;
142 1         4 foreach (@modules) { $_ =~ s/(\S+)-[\d.]+$/$1/ };
  3         8  
143              
144 1         4 $self->{_only} = { map { $_ => 0 } @modules };
  3         9  
145             }
146 74         363 return $self->{_only};
147             }
148              
149             sub exclude {
150 74     74 0 254 my ($self, $exclude) = @_;
151 74 100       192 if ($exclude) {
152 1         7 $exclude =~ s/::/-/g;
153 1         10 my @modules = split /\s*,\s*/, $exclude;
154 1         4 foreach (@modules) { $_ =~ s/(\S+)-[\d.]+$/$1/ };
  3         11  
155              
156 1         4 $self->{_exclude} = { map { $_ => 0 } @modules };
  3         10  
157             }
158 74         372 return $self->{_exclude};
159             }
160              
161             sub build_dir {
162 26     26 0 1461 my ($self, $dir) = @_;
163 26 100       121 $self->{_build_dir} = $dir if $dir;
164 26         106 return $self->{_build_dir};
165             }
166              
167             sub build_logfile {
168 40     40 0 115 my ($self, $file) = @_;
169 40 100       121 $self->{_build_logfile} = $file if $file;
170 40         105 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   61 my ($self, $build_logfile) = @_;
207              
208 18         54 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         411 my $mtime = (stat $build_logfile)[9];
214 18         152 my $age_in_minutes = int((time - $mtime) / 60);
215 18 0 33     67 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         68 return 1;
237             }
238              
239             sub _get_logfiles {
240 18     18   52 my ($self) = @_;
241 18         36 my @files;
242 18 50       63 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         63 push @files, $self->build_logfile;
265             }
266 18         74 return @files;
267             }
268              
269             sub run {
270 18     18 0 1218 my $self = shift;
271 18 50       67 return unless $self->_check_cpantesters_config_data;
272 18         155 foreach my $logfile ($self->_get_logfiles) {
273 18         68 $self->process_logfile($logfile);
274             }
275 18         1521 return;
276             }
277              
278             sub process_logfile {
279 18     18 0 50 my ($self, $logfile) = @_;
280              
281 18 50       69 return unless $self->_check_build_log($logfile);
282              
283 18 50       806 open my $fh, '<', $logfile
284             or Carp::croak "error opening build log file '$logfile' for reading: $!";
285              
286 18         929 my $header = <$fh>;
287 18 50       207 if ($header =~ /^cpanm \(App::cpanminus\) (\d+\.\d+) on perl (\d+\.\d+)/) {
288 18         110 $self->{_cpanminus_version} = $1;
289 18         89 $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         44 my $found = 0;
301 18         35 my $parser;
302              
303             # we could go over 100 levels deep on the dependency track
304 21     21   202 no warnings 'recursion';
  21         54  
  21         47342  
305             $parser = sub {
306 90     90   213 my ($dist, $resource) = @_;
307 90 100       483 (my $dist_vstring = $dist) =~ s/\-(\d+(?:\.\d)+)$/-v$1/ if $dist;
308 90         186 my @test_output = ();
309 90         139 my $recording;
310 90         137 my $has_tests = 0;
311 90         159 my $found_na;
312             my $fetched;
313              
314 90         654 while (<$fh>) {
315 7396 100 100     38266 if ( /^Fetching (\S+)/ ) {
    100          
    100          
    100          
316 71 50       217 next if /CHECKSUMS$/;
317 71         218 $fetched = $1;
318 71 100       177 $resource = $fetched unless $resource;
319             }
320             elsif ( /^Entering (\S+)/ ) {
321 72         256 my $dep = $1;
322 72         138 $found = 1;
323 72 50 66     307 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     230 print "entering $dep, " . ($fetched || '(local)') . "\n" if $self->verbose;
328 72         485 $parser->($dep, $fetched);
329 72 50 0     203 print "left $dep, " . ($fetched || '(local)') . "\n" if $self->verbose;
330 72         677 next;
331             }
332             }
333             elsif ( /^Running (?:Build|Makefile)\.PL/ ) {
334 73         183 $recording = 'configure';
335             }
336             elsif ( $dist and /^Building .*(?:$dist|$dist_vstring)/) {
337 68 50       218 print "recording $dist\n" if $self->verbose;
338 68 100       283 $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         305 @test_output = ();
343 68         160 $recording = 'test';
344             }
345              
346 7324 100       16771 push @test_output, $_ if $recording;
347              
348 7324         9565 my $result;
349 7324 100       11805 if ($recording) {
350 5854 100 100     41605 if ( /^Result: (PASS|NA|FAIL|UNKNOWN|NOTESTS)/
    100 100        
    100 100        
      66        
      66        
351             || ($recording eq 'test' && /^-> (FAIL|OK)/)
352             ) {
353 69         194 $result = $1;
354 69 50 66     480 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       18 $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         4 $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       8 $result = $found_na ? 'NA' : 'UNKNOWN';
379             }
380             }
381 7324 100       22175 if ($result) {
382 71         126 my $dist_without_version = $dist;
383 71         417 $dist_without_version =~ s/(\S+)-[\d.]+$/$1/;
384              
385 71 100 33     383 if (@test_output <= 2) {
    50 33        
    50 66        
    50 66        
    100          
386 1         68 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       8 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         388 my $report = $self->make_report($resource, $dist, $result, @test_output);
403             }
404 71         46575 return;
405             }
406             }
407 18         153 };
408              
409 18 50       90 print "Parsing $logfile...\n" if $self->verbose;
410 18         64 $parser->();
411 18 50 66     160 print "No reports found.\n" if !$found and $self->verbose;
412 18 50       85 print "Finished.\n" if $self->verbose;
413              
414 18         278 close $fh;
415 18         153 return;
416             }
417              
418             sub get_author {
419 61     61 0 191 my ($self, $path) = @_;
420 61 100       170 if ($path->scheme eq 'file') {
421 1         30 return $self->_get_author_from_file($path);
422             }
423             else {
424 60         957 return $self->_get_author_from_metabase($path->path);
425             }
426             }
427              
428             sub _get_author_from_file {
429 1     1   4 my ($self, $path) = @_;
430              
431 1         38 my $directories = (File::Spec->splitpath($path))[1];
432 1         61 my @path = File::Spec->splitdir($directories);
433 1 50       10 pop @path if $path[-1] eq '';
434              
435 1 50 33     43 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         9 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   758 my ($self, $path) = @_;
450 60         126 my $metadata;
451              
452             try {
453 60     60   3085 $metadata = Metabase::Resource->new( q[cpan:///distfile/] . $path )->metadata;
454             }
455             catch {
456 0 0   0   0 print "DEBUG: $_" if $self->verbose;
457 60         430 };
458 60 50       60896 return unless $metadata;
459              
460 60         285 return $metadata->{cpan_id};
461             }
462              
463              
464             # returns false in case of error (so, skip!)
465             sub parse_uri {
466 61     61 0 5423 my ($self, $resource) = @_;
467              
468 61         265 my $uri = URI->new( $resource );
469 61         83617 my $scheme = lc $uri->scheme;
470 61         1853 my %eligible_schemes = map {$_ => 1} (qw| http https ftp cpan file |);
  305         736  
471 61 50       217 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         207 my $author = $self->get_author( $uri );
478              
479 61 50       180 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       164 if ($author eq 'LOCAL') {
487 1 50       4 print "'LOCAL' user is reserved. Skipping resource '$resource'\n"
488             unless $self->quiet;
489 1         17 return;
490             }
491              
492 60         203 $self->author($author);
493              
494 60         203 $self->distfile(substr("$uri", index("$uri", $author)));
495              
496 60         317 return 1;
497             }
498              
499             sub make_report {
500 4     4 0 29 my ($self, $resource, $dist, $result, @test_output) = @_;
501              
502 4 100       19 if ( index($dist, 'Local-') == 0 ) {
503 1 50       4 print "'Local::' namespace is reserved. Skipping resource '$resource'\n"
504             unless $self->quiet;
505 1         5 return;
506             }
507 3 100       11 return unless $self->parse_uri($resource);
508              
509 2         6 my $author = $self->author;
510              
511 2   100     10 my $cpanm_version = $self->{_cpanminus_version} || 'unknown cpanm version';
512 2         7 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     11 );
521              
522 2 50 33     123 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       7 print "sending: ($resource, $author, $dist, $result)\n" unless $self->quiet;
529             }
530              
531 2         9 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       627451 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   306 $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   850 $client->record_history unless $self->skip_history;
555 2         69 };
556 2         212 return;
557             }
558              
559             sub get_meta_for {
560 2     2 0 6 my ($self, $dist) = @_;
561 2         7 my $distdir = File::Spec->catdir( $self->build_dir, 'latest-build', $dist );
562              
563 2         6 foreach my $meta_file ( qw( MYMETA.json MYMETA.yml META.json META.yml ) ) {
564 8         67 my $meta_path = File::Spec->catfile( $distdir, $meta_file );
565 8 50       109 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__