File Coverage

blib/lib/App/cpanminus/reporter.pm
Criterion Covered Total %
statement 244 308 79.2
branch 107 188 56.9
condition 41 80 51.2
subroutine 46 52 88.4
pod 0 23 0.0
total 438 651 67.2


line stmt bran cond sub pod time code
1             package App::cpanminus::reporter;
2              
3 20     20   1407626 use warnings;
  20         226  
  20         674  
4 20     20   178 use strict;
  20         43  
  20         842  
5              
6             our $VERSION = '0.19';
7              
8 20     20   145 use Carp ();
  20         72  
  20         624  
9 20     20   137 use File::Spec 3.19;
  20         745  
  20         534  
10 20     20   8846 use File::HomeDir::Tiny ();
  20         5814  
  20         497  
11 20     20   10111 use Test::Reporter 1.54;
  20         678272  
  20         843  
12 20     20   11761 use CPAN::Testers::Common::Client 0.13;
  20         3965366  
  20         822  
13 20     20   222 use CPAN::Testers::Common::Client::Config;
  20         44  
  20         463  
14 20     20   10829 use Parse::CPAN::Meta;
  20         31939  
  20         1147  
15 20     20   12635 use CPAN::Meta::Converter;
  20         409185  
  20         1001  
16 20     20   11007 use Try::Tiny;
  20         41086  
  20         1286  
17 20     20   11035 use URI;
  20         94420  
  20         712  
18 20     20   9399 use Metabase::Resource;
  20         25376  
  20         753  
19 20     20   154 use Capture::Tiny qw(capture);
  20         58  
  20         1407  
20 20     20   8839 use IO::Prompt::Tiny ();
  20         10789  
  20         38536  
21              
22             sub new {
23 20     20 0 3125 my ($class, %params) = @_;
24 20         79 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 20         379 );
31              
32 20 50       94 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 20   33     636 || File::Spec->catdir( File::HomeDir::Tiny::home(), '.cpanm' )
46             );
47              
48             $self->build_logfile(
49             $params{build_logfile}
50 20   66     166 || File::Spec->catfile( $self->build_dir, 'build.log' )
51             );
52              
53 20   50     201 $self->max_age($params{max_age} || 30);
54              
55 20         81 foreach my $option ( qw(quiet verbose force exclude only dry-run skip-history ignore-versions all) ) {
56 180         298 my $method = $option;
57 180         365 $method =~ s/\-/_/g;
58 180 100       537 $self->$method( $params{$option} ) if exists $params{$option};
59             }
60              
61 20         139 return $self;
62             }
63              
64 0     0 0 0 sub setup { shift->config->setup }
65              
66             ## basic accessors ##
67              
68             sub author {
69 76     76 0 199 my ($self, $author) = @_;
70 76 100       233 $self->{_author} = $author if $author;
71 76         233 return $self->{_author};
72             }
73              
74             sub distfile {
75 74     74 0 1520 my ($self, $distfile) = @_;
76 74 100       234 $self->{_distfile} = $distfile if $distfile;
77 74         191 return $self->{_distfile};
78             }
79              
80             sub config {
81 27     27 0 1649 my ($self, $config) = @_;
82 27 100       145 $self->{_config} = $config if $config;
83 27         99 return $self->{_config};
84             }
85              
86             sub verbose {
87 252     252 0 564 my ($self, $verbose) = @_;
88 252 100       541 $self->{_verbose} = $verbose if $verbose;
89 252         780 return $self->{_verbose};
90             }
91              
92             sub all {
93 17     17 0 50 my ($self, $all) = @_;
94 17 50       52 $self->{_all} = $all if $all;
95 17         117 return $self->{_all};
96             }
97              
98             sub max_age {
99 37     37 0 102 my ($self, $max_age) = @_;
100 37 100       112 $self->{_max_age} = $max_age if $max_age;
101 37         78 return $self->{_max_age};
102             }
103              
104             sub force {
105 39     39 0 107 my ($self, $force) = @_;
106 39 100       135 $self->{_force} = $force if $force;
107 39         138 return $self->{_force};
108             }
109              
110             sub ignore_versions {
111 86     86 0 194 my ($self, $ignore_versions) = @_;
112 86 100       259 $self->{_ignore_versions} = $ignore_versions if $ignore_versions;
113 86         351 return $self->{_ignore_versions};
114             }
115              
116             sub quiet {
117 11     11 0 26 my ($self, $quiet) = @_;
118 11 100       29 if ($quiet) {
119 3         19 $self->verbose(0);
120 3         7 $self->{_quiet} = 1;
121             }
122 11         37 return $self->{_quiet};
123             }
124              
125             sub dry_run {
126 2     2 0 21 my ($self, $dry_run) = @_;
127 2 50       27 $self->{_dry_run} = $dry_run if $dry_run;
128 2         18 $self->{_dry_run};
129             }
130              
131             sub skip_history {
132 6     6 0 22 my ($self, $skip) = @_;
133 6 100       20 $self->{_skip_history} = $skip if $skip;
134 6         28 $self->{_skip_history};
135             }
136              
137             sub only {
138 73     73 0 181 my ($self, $only) = @_;
139 73 100       172 if ($only) {
140 1         7 $only =~ s/::/-/g;
141 1         14 my @modules = split /\s*,\s*/, $only;
142 1         5 foreach (@modules) { $_ =~ s/(\S+)-[\d.]+$/$1/ };
  3         11  
143              
144 1         4 $self->{_only} = { map { $_ => 0 } @modules };
  3         9  
145             }
146 73         325 return $self->{_only};
147             }
148              
149             sub exclude {
150 73     73 0 192 my ($self, $exclude) = @_;
151 73 100       207 if ($exclude) {
152 1         7 $exclude =~ s/::/-/g;
153 1         11 my @modules = split /\s*,\s*/, $exclude;
154 1         6 foreach (@modules) { $_ =~ s/(\S+)-[\d.]+$/$1/ };
  3         9  
155              
156 1         4 $self->{_exclude} = { map { $_ => 0 } @modules };
  3         11  
157             }
158 73         415 return $self->{_exclude};
159             }
160              
161             sub build_dir {
162 25     25 0 1397 my ($self, $dir) = @_;
163 25 100       118 $self->{_build_dir} = $dir if $dir;
164 25         107 return $self->{_build_dir};
165             }
166              
167             sub build_logfile {
168 38     38 0 112 my ($self, $file) = @_;
169 38 100       133 $self->{_build_logfile} = $file if $file;
170 38         100 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 17     17   51 my ($self, $build_logfile) = @_;
207              
208 17         55 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 17         400 my $mtime = (stat $build_logfile)[9];
214 17         146 my $age_in_minutes = int((time - $mtime) / 60);
215 17 0 33     79 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 17         65 return 1;
237             }
238              
239             sub _get_logfiles {
240 17     17   51 my ($self) = @_;
241 17         44 my @files;
242 17 50       67 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 17         59 push @files, $self->build_logfile;
265             }
266 17         58 return @files;
267             }
268              
269             sub run {
270 17     17 0 1085 my $self = shift;
271 17 50       59 return unless $self->_check_cpantesters_config_data;
272 17         146 foreach my $logfile ($self->_get_logfiles) {
273 17         71 $self->process_logfile($logfile);
274             }
275 17         1420 return;
276             }
277              
278             sub process_logfile {
279 17     17 0 62 my ($self, $logfile) = @_;
280              
281 17 50       66 return unless $self->_check_build_log($logfile);
282              
283 17 50       747 open my $fh, '<', $logfile
284             or Carp::croak "error opening build log file '$logfile' for reading: $!";
285              
286 17         949 my $header = <$fh>;
287 17 50       199 if ($header =~ /^cpanm \(App::cpanminus\) (\d+\.\d+) on perl (\d+\.\d+)/) {
288 17         104 $self->{_cpanminus_version} = $1;
289 17         87 $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 17         45 my $found = 0;
301 17         75 my $parser;
302              
303             # we could go over 100 levels deep on the dependency track
304 20     20   196 no warnings 'recursion';
  20         60  
  20         44693  
305             $parser = sub {
306 88     88   214 my ($dist, $resource) = @_;
307 88 100       483 (my $dist_vstring = $dist) =~ s/\-(\d+(?:\.\d)+)$/-v$1/ if $dist;
308 88         185 my @test_output = ();
309 88         128 my $recording;
310 88         136 my $has_tests = 0;
311 88         153 my $found_na;
312             my $fetched;
313              
314 88         589 while (<$fh>) {
315 7365 100 100     38062 if ( /^Fetching (\S+)/ ) {
    100          
    100          
    100          
316 70 50       198 next if /CHECKSUMS$/;
317 70         209 $fetched = $1;
318 70 100       190 $resource = $fetched unless $resource;
319             }
320             elsif ( /^Entering (\S+)/ ) {
321 71         194 my $dep = $1;
322 71         118 $found = 1;
323 71 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 71 50 0     239 print "entering $dep, " . ($fetched || '(local)') . "\n" if $self->verbose;
328 71         467 $parser->($dep, $fetched);
329 71 50 0     220 print "left $dep, " . ($fetched || '(local)') . "\n" if $self->verbose;
330 71         656 next;
331             }
332             }
333             elsif ( /^Running (?:Build|Makefile)\.PL/ ) {
334 72         159 $recording = 'configure';
335             }
336             elsif ( $dist and /^Building .*(?:$dist|$dist_vstring)/) {
337 67 50       220 print "recording $dist\n" if $self->verbose;
338 67 100       254 $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 67         409 @test_output = ();
343 67         144 $recording = 'test';
344             }
345              
346 7294 100       16372 push @test_output, $_ if $recording;
347              
348 7294         9664 my $result;
349 7294 100       11825 if ($recording) {
350 5832 100 100     42426 if ( /^Result: (PASS|NA|FAIL|UNKNOWN|NOTESTS)/
    100 100        
    100 100        
      66        
      66        
351             || ($recording eq 'test' && /^-> (FAIL|OK)/)
352             ) {
353 68         217 $result = $1;
354 68 50 66     364 if ($result eq 'FAIL' && $recording eq 'configure') {
    100          
    50          
355 0         0 $result = 'NA';
356             }
357             elsif ($result eq 'OK') {
358 4 100       479 $result = $has_tests ? 'PASS' : 'UNKNOWN';
359             }
360             elsif ($result eq 'NOTESTS') {
361 0         0 $result = 'UNKNOWN';
362             }
363             }
364             elsif ( $recording eq 'configure' && /^-> N\/A/ ) {
365 2         7 $found_na = 1;
366             }
367             elsif ( $recording eq 'configure'
368             # https://github.com/miyagawa/cpanminus/blob/devel/lib/App/cpanminus/script.pm#L2269
369             && ( /Configure failed for (?:$dist|$dist_vstring)/
370             || /proper Makefile.PL\/Build.PL/
371             || /configure the distribution/
372             )
373             ) {
374 2 50       14 $result = $found_na ? 'NA' : 'UNKNOWN';
375             }
376             }
377 7294 100       22133 if ($result) {
378 70         131 my $dist_without_version = $dist;
379 70         465 $dist_without_version =~ s/(\S+)-[\d.]+$/$1/;
380              
381 70 100 33     403 if (@test_output <= 2) {
    50 33        
    50 66        
    50 66        
    100          
382 1         62 print "No test output found for '$dist'. Skipping...\n"
383             . "To send test reports, please make sure *NOT* to pass '-v' to cpanm or your build.log will contain no output to send.\n";
384             }
385             elsif (!$resource) {
386 0         0 print "Skipping report for local installation of '$dist'.\n";
387             }
388             elsif ( defined $self->exclude && exists $self->exclude->{$dist_without_version} ) {
389 0 0       0 print "Skipping $dist as it's in the 'exclude' list...\n" if $self->verbose;
390             }
391             elsif ( defined $self->only && !exists $self->only->{$dist_without_version} ) {
392 0 0       0 print "Skipping $dist as it isn't in the 'only' list...\n" if $self->verbose;
393             }
394             elsif ( !$self->ignore_versions && defined $self->{_perl_version} && ( $self->{_perl_version} ne $] ) ) {
395 1 50       8 print "Skipping $dist as its build Perl version ($self->{_perl_version}) differs from the currently running perl ($])...\n" if $self->verbose;
396             }
397             else {
398 68         376 my $report = $self->make_report($resource, $dist, $result, @test_output);
399             }
400 70         49193 return;
401             }
402             }
403 17         172 };
404              
405 17 50       93 print "Parsing $logfile...\n" if $self->verbose;
406 17         74 $parser->();
407 17 50 66     219 print "No reports found.\n" if !$found and $self->verbose;
408 17 50       85 print "Finished.\n" if $self->verbose;
409              
410 17         231 close $fh;
411 17         148 return;
412             }
413              
414             sub get_author {
415 60     60 0 179 my ($self, $path) = @_;
416 60 100       210 if ($path->scheme eq 'file') {
417 1         40 return $self->_get_author_from_file($path);
418             }
419             else {
420 59         989 return $self->_get_author_from_metabase($path->path);
421             }
422             }
423              
424             sub _get_author_from_file {
425 1     1   4 my ($self, $path) = @_;
426              
427 1         45 my $directories = (File::Spec->splitpath($path))[1];
428 1         59 my @path = File::Spec->splitdir($directories);
429 1 50       6 pop @path if $path[-1] eq '';
430              
431 1 50 33     43 if ( @path >= 3 # R/RJ/RJBS
      33        
      33        
432             && $path[-1] =~ /\A[A-Z\-]+\z/ # RJBS
433             && substr($path[-1], 0, 2) eq $path[-2] # RJ
434             && substr($path[-1], 0, 1) eq $path[-3] # R
435             ) {
436 1         8 return $path[-1];
437             }
438             else {
439 0 0       0 print "DEBUG: path '$path' doesn't look valid" if $self->verbose;
440 0         0 return;
441             }
442             }
443              
444             sub _get_author_from_metabase {
445 59     59   774 my ($self, $path) = @_;
446 59         116 my $metadata;
447              
448             try {
449 59     59   3012 $metadata = Metabase::Resource->new( q[cpan:///distfile/] . $path )->metadata;
450             }
451             catch {
452 0 0   0   0 print "DEBUG: $_" if $self->verbose;
453 59         474 };
454 59 50       56393 return unless $metadata;
455              
456 59         288 return $metadata->{cpan_id};
457             }
458              
459              
460             # returns false in case of error (so, skip!)
461             sub parse_uri {
462 60     60 0 7173 my ($self, $resource) = @_;
463              
464 60         268 my $uri = URI->new( $resource );
465 60         78208 my $scheme = lc $uri->scheme;
466 60         1876 my %eligible_schemes = map {$_ => 1} (qw| http https ftp cpan file |);
  300         797  
467 60 50       254 if (! $eligible_schemes{$scheme}) {
468 0 0       0 print "invalid scheme '$scheme' for resource '$resource'. Skipping...\n"
469             unless $self->quiet;
470 0         0 return;
471             }
472              
473 60         196 my $author = $self->get_author( $uri );
474              
475 60 50       174 unless (defined $author) {
476 0 0       0 print "error fetching author for resource '$resource'. Skipping...\n"
477             unless $self->quiet;
478 0         0 return;
479             }
480              
481             # the 'LOCAL' user is reserved and should never send reports.
482 60 100       160 if ($author eq 'LOCAL') {
483 1 50       3 print "'LOCAL' user is reserved. Skipping resource '$resource'\n"
484             unless $self->quiet;
485 1         15 return;
486             }
487              
488 59         201 $self->author($author);
489              
490 59         210 $self->distfile(substr("$uri", index("$uri", $author)));
491              
492 59         317 return 1;
493             }
494              
495             sub make_report {
496 4     4 0 34 my ($self, $resource, $dist, $result, @test_output) = @_;
497              
498 4 100       19 if ( index($dist, 'Local-') == 0 ) {
499 1 50       3 print "'Local::' namespace is reserved. Skipping resource '$resource'\n"
500             unless $self->quiet;
501 1         6 return;
502             }
503 3 100       11 return unless $self->parse_uri($resource);
504              
505 2         11 my $author = $self->author;
506              
507 2   100     17 my $cpanm_version = $self->{_cpanminus_version} || 'unknown cpanm version';
508 2         10 my $meta = $self->get_meta_for( $dist );
509             my $client = CPAN::Testers::Common::Client->new(
510             author => $self->author,
511             distname => $dist,
512             grade => $result,
513             via => "App::cpanminus::reporter $VERSION ($cpanm_version)",
514             test_output => join( '', @test_output ),
515             prereqs => ($meta && ref $meta) ? $meta->{prereqs} : undef,
516 2 50 33     13 );
517              
518 2 50 33     146 if (!$self->skip_history && $client->is_duplicate) {
519 0 0       0 print "($resource, $author, $dist, $result) was already sent. Skipping...\n"
520             if $self->verbose;
521 0         0 return;
522             }
523             else {
524 2 50       8 print "sending: ($resource, $author, $dist, $result)\n" unless $self->quiet;
525             }
526              
527 2         11 my $reporter = Test::Reporter->new(
528             transport => $self->config->transport_name,
529             transport_args => $self->config->transport_args,
530             grade => $client->grade,
531             distribution => $dist,
532             distfile => $self->distfile,
533             from => $self->config->email_from,
534             comments => $client->email,
535             via => $client->via,
536             );
537              
538 2 50       662871 if ($self->dry_run) {
539 0 0       0 print "not sending (dry run)\n" unless $self->quiet;
540 0         0 return;
541             }
542              
543             try {
544 2 50   2   355 $reporter->send() || die $reporter->errstr();
545             }
546             catch {
547 0 0   0   0 print "Error while sending this report, continuing with the next one ($_)...\n" unless $self->quiet;
548 0 0       0 print "DEBUG: @_" if $self->verbose;
549             } finally{
550 2 50   2   948 $client->record_history unless $self->skip_history;
551 2         82 };
552 2         241 return;
553             }
554              
555             sub get_meta_for {
556 2     2 0 5 my ($self, $dist) = @_;
557 2         8 my $distdir = File::Spec->catdir( $self->build_dir, 'latest-build', $dist );
558              
559 2         10 foreach my $meta_file ( qw( MYMETA.json MYMETA.yml META.json META.yml ) ) {
560 8         79 my $meta_path = File::Spec->catfile( $distdir, $meta_file );
561 8 50       127 if (-e $meta_path) {
562 0         0 my $meta = eval { Parse::CPAN::Meta->load_file( $meta_path ) };
  0         0  
563 0 0       0 next if $@;
564              
565 0 0 0     0 if (!$meta->{'meta-spec'} or $meta->{'meta-spec'}{version} < 2) {
566 0         0 $meta = CPAN::Meta::Converter->new( $meta )->convert( version => 2 );
567             }
568 0         0 return $meta;
569             }
570             }
571 2         9 return;
572             }
573              
574              
575             42;
576             __END__