File Coverage

blib/lib/App/SCM/Digest.pm
Criterion Covered Total %
statement 138 264 52.2
branch 18 64 28.1
condition 2 16 12.5
subroutine 28 32 87.5
pod 3 3 100.0
total 189 379 49.8


line stmt bran cond sub pod time code
1             package App::SCM::Digest;
2              
3 7     7   546516 use strict;
  7         80  
  7         223  
4 7     7   41 use warnings;
  7         14  
  7         262  
5              
6 7     7   1985 use App::SCM::Digest::Utils qw(system_ad slurp);
  7         24  
  7         437  
7 7     7   2302 use App::SCM::Digest::SCM::Factory;
  7         26  
  7         215  
8              
9 7     7   59 use autodie;
  7         14  
  7         31  
10 7     7   37943 use DateTime;
  7         3065086  
  7         325  
11 7     7   5005 use DateTime::Format::Strptime;
  7         391872  
  7         47  
12 7     7   4552 use Getopt::Long;
  7         65022  
  7         37  
13 7     7   3972 use Email::MIME;
  7         174152  
  7         218  
14 7     7   2188 use File::Copy;
  7         12873  
  7         409  
15 7     7   48 use File::Path;
  7         16  
  7         403  
16 7     7   1899 use File::ReadBackwards;
  7         6698  
  7         207  
17 7     7   73 use File::Temp qw(tempdir);
  7         14  
  7         447  
18 7     7   46 use List::Util qw(first);
  7         20  
  7         351  
19 7     7   49 use POSIX qw();
  7         9  
  7         118  
20              
21 7     7   33 use constant PATTERN => '%FT%T';
  7         13  
  7         573  
22 7         16064 use constant EMAIL_ATTRIBUTES => (
23             content_type => 'text/plain',
24             disposition => 'attachment',
25             charset => 'UTF-8',
26             encoding => 'quoted-printable',
27 7     7   38 );
  7         12  
28              
29             our $VERSION = '0.13';
30              
31             sub new
32             {
33 2     2 1 2124 my ($class, $config) = @_;
34 2         16 my $self = { config => $config };
35 2         11 bless $self, $class;
36 2         8 return $self;
37             }
38              
39             sub _strftime
40             {
41 0     0   0 my ($time) = @_;
42              
43 0         0 return POSIX::strftime(PATTERN, gmtime($time));
44             }
45              
46             sub _impl
47             {
48 12     12   34 my ($name) = @_;
49              
50 12         174 return App::SCM::Digest::SCM::Factory->new($name);
51             }
52              
53             sub _load_repository
54             {
55 12     12   50 my ($repository) = @_;
56              
57 12         32 my ($name, $url, $type) = @{$repository}{qw(name url type)};
  12         69  
58 12         52 my $impl = _impl($type);
59              
60 12         122 return ($name, $impl);
61             }
62              
63             sub _load_and_open_repository
64             {
65 4     4   18 my ($repository) = @_;
66              
67 4         19 my ($name, $impl) = _load_repository($repository);
68 4         45 eval { $impl->open_repository($name) };
  4         41  
69 4 100       7526 if (my $error = $@) {
70 3         543 die "Unable to open repository '$name': $error";
71             }
72              
73 1         8 return ($name, $impl);
74             }
75              
76             sub _init_repository
77             {
78 3     3   17 my ($repo_path, $db_path, $repository) = @_;
79              
80 3         37 chdir $repo_path;
81 3         3601 my ($name, $impl) = _load_repository($repository);
82 3         65 my $pre_existing = (-e $name);
83 3 50       32 if (not $pre_existing) {
84 3 100       95 if (not -e "$db_path/$name") {
85 2         36 mkdir "$db_path/$name";
86             }
87 3         2375 $impl->clone($repository->{'url'}, $name);
88             }
89 1         97 $impl->open_repository($name);
90 1 50       7 if (not $impl->is_usable()) {
91 1         18 return;
92             }
93 0 0       0 if ($pre_existing) {
94 0         0 $impl->pull();
95             }
96 0         0 my @branches = @{$impl->branches()};
  0         0  
97 0         0 for my $branch (@branches) {
98 0         0 my ($branch_name, $commit) = @{$branch};
  0         0  
99 0         0 my $branch_db_path = "$db_path/$name/$branch_name";
100 0 0       0 if (-e $branch_db_path) {
101 0         0 next;
102             }
103 0         0 my @branch_db_segments = split /\//, $branch_db_path;
104 0         0 pop @branch_db_segments;
105 0         0 my $branch_db_parent = join '/', @branch_db_segments;
106 0 0       0 if (not -e $branch_db_parent) {
107 0         0 system_ad("mkdir -p $branch_db_parent");
108             }
109 0         0 open my $fh, '>', $branch_db_path;
110 0         0 print $fh _strftime(time()).".$commit\n";
111 0         0 close $fh;
112             }
113              
114 0         0 return 1;
115             }
116              
117             sub _update_repository
118             {
119 2     2   11 my ($repo_path, $db_path, $repository) = @_;
120              
121 2         21 chdir $repo_path;
122 2         179 my ($name, $impl) = _load_and_open_repository($repository);
123 1 50       6 if (not $impl->is_usable()) {
124 1         22 return;
125             }
126 0         0 $impl->pull();
127 0         0 my $current_branch = $impl->branch_name();
128 0         0 my @branches = @{$impl->branches()};
  0         0  
129 0         0 for my $branch (@branches) {
130 0         0 my ($branch_name, undef) = @{$branch};
  0         0  
131 0         0 my $branch_db_path = "$db_path/$name/$branch_name";
132 0 0       0 if (not -e $branch_db_path) {
133 0         0 die "Unable to find branch database ($branch_db_path).";
134             }
135 0 0       0 my $branch_db_file =
136             File::ReadBackwards->new($branch_db_path)
137             or die "Unable to load branch database ".
138             "($branch_db_path).";
139              
140 0         0 my ($last, $commit);
141 0         0 do {
142 0   0     0 $last = $branch_db_file->readline() || '';
143 0         0 chomp $last;
144 0         0 (undef, $commit) = split /\./, $last;
145 0 0       0 if (not $commit) {
146 0         0 die "Unable to find commit ID in database.";
147             }
148             } while (not $impl->has($commit));
149              
150 0         0 my @new_commits = @{$impl->commits_from($branch_name, $commit)};
  0         0  
151 0         0 my $time = _strftime(time());
152 0         0 open my $fh, '>>', $branch_db_path;
153 0         0 for my $new_commit (@new_commits) {
154 0         0 print $fh "$time.$new_commit\n";
155             }
156 0         0 close $fh;
157             }
158 0         0 $impl->checkout($current_branch);
159              
160 0         0 return 1;
161             }
162              
163             sub _repository_map
164             {
165 7     7   29 my ($self, $method) = @_;
166              
167 7         25 my $config = $self->{'config'};
168              
169             my ($repo_path, $db_path, $repositories) =
170 7         20 @{$config}{qw(repository_path db_path repositories)};
  7         37  
171              
172 7         19 for my $repository (@{$repositories}) {
  7         26  
173 7         31 eval {
174 7         38 $method->($repo_path, $db_path, $repository);
175             };
176 7 100       1161 if (my $error = $@) {
177 5         56 chdir $repo_path;
178 5         573 my ($name, $impl) = _load_repository($repository);
179 5         61 my $backup_dir = tempdir(CLEANUP => 1);
180 5         2852 my $backup_path = $backup_dir.'/temporary';
181 5         29 my $do_backup = (-e $name);
182 5 50       26 if ($do_backup) {
183 0         0 my $res = move($name, $backup_path);
184 0 0       0 if (not $res) {
185 0         0 warn "Unable to backup repository for re-clone: $!";
186             }
187             }
188 5         20 eval {
189 5         53 $impl->clone($repository->{'url'}, $name);
190 0         0 $method->($repo_path, $db_path, $repository);
191             };
192 5 50       7341 if (my $sub_error = $@) {
193 5 50       28 if ($do_backup) {
194 0         0 my $rm_error;
195 0         0 rmtree($name, { error => \$rm_error });
196 0 0 0     0 if ($rm_error and @{$rm_error}) {
  0         0  
197             my $info =
198             join ', ',
199 0         0 map { join ':', %{$_} }
  0         0  
200 0         0 @{$rm_error};
  0         0  
201 0         0 warn "Unable to restore repository: ".$info;
202             } else {
203 0         0 my $res = move($backup_path, $name);
204 0 0       0 if (not $res) {
205 0         0 warn "Unable to restore repository on ".
206             "failed rerun: $!";
207             }
208             }
209             }
210 5         59 my $error_msg = "Re-clone or nested operation failed: ".
211             "$sub_error (original error was $error)";
212 5 100       32 if ($config->{'ignore_errors'}) {
213 3         69 warn $error_msg;
214             } else {
215 2         233 die $error_msg;
216             }
217             } else {
218 0         0 warn "Re-cloned '$name' due to error: $error";
219             }
220             }
221             }
222             }
223              
224             sub update
225             {
226 3     3 1 2448 my ($self) = @_;
227              
228 3         20 $self->_repository_map(\&_init_repository);
229 2         38 $self->_repository_map(\&_update_repository);
230              
231 2         30 return 1;
232             }
233              
234             sub _process_bounds
235             {
236 2     2   11 my ($self, $from, $to) = @_;
237              
238 2         9 my $config = $self->{'config'};
239 2   50     25 my $tz = $config->{'timezone'} || 'UTC';
240              
241 2 50 33     19 if (not defined $from and not defined $to) {
    0          
    0          
242 2         30 $from = DateTime->now(time_zone => $tz)
243             ->subtract(days => 1)
244             ->strftime(PATTERN);
245 2         4344 $to = DateTime->now(time_zone => $tz)
246             ->strftime(PATTERN);
247             } elsif (not defined $from) {
248 0         0 $from = '0000-01-01T00:00:00';
249             } elsif (not defined $to) {
250 0         0 $to = '9999-12-31T23:59:59';
251             }
252              
253 2         791 my $strp =
254             DateTime::Format::Strptime->new(pattern => PATTERN,
255             time_zone => $tz);
256              
257             my ($from_dt, $to_dt) =
258 2         3501 map { $strp->parse_datetime($_) }
  4         1716  
259             ($from, $to);
260 2 50       1284 if (not $from_dt) {
261 0         0 die "Invalid 'from' time provided.";
262             }
263 2 50       13 if (not $to_dt) {
264 0         0 die "Invalid 'to' time provided.";
265             }
266              
267             ($from, $to) =
268 2         15 map { $_->set_time_zone('UTC');
  4         168  
269 4         48 $_->strftime(PATTERN) }
270             ($from_dt, $to_dt);
271              
272 2         242 return ($from, $to);
273             }
274              
275             sub _utc_to_tz
276             {
277 0     0   0 my ($self, $datetime) = @_;
278              
279 0         0 my $config = $self->{'config'};
280 0         0 my $tz = $config->{'timezone'};
281 0 0 0     0 if ((not $tz) or ($tz eq 'UTC')) {
282 0         0 return $datetime;
283             }
284              
285 0         0 my $strp =
286             DateTime::Format::Strptime->new(pattern => PATTERN,
287             time_zone => 'UTC');
288              
289 0         0 my $dt = $strp->parse_datetime($datetime);
290 0         0 $dt->set_time_zone($tz);
291 0         0 return $dt->strftime(PATTERN);
292             }
293              
294             sub _load_commits
295             {
296 0     0   0 my ($branch_db_path, $from, $to) = @_;
297              
298 0 0       0 if (not -e $branch_db_path) {
299 0         0 die "Unable to find branch database ($branch_db_path).";
300             }
301 0         0 open my $fh, '<', $branch_db_path;
302 0         0 my @commits;
303 0         0 while (my $entry = <$fh>) {
304 0         0 chomp $entry;
305 0         0 my ($time, $id) = split /\./, $entry;
306 0 0 0     0 if (($time ge $from) and ($time le $to)) {
307 0         0 push @commits, [ $time, $id ];
308             }
309             }
310 0         0 close $fh;
311              
312 0         0 return @commits;
313             }
314              
315             sub _make_email_mime
316             {
317 0     0   0 my ($content, $filename) = @_;
318              
319             return
320 0         0 Email::MIME->create(
321             attributes => { EMAIL_ATTRIBUTES,
322             filename => $filename },
323             body_str => $content
324             );
325             }
326              
327             sub get_email
328             {
329 2     2 1 2662 my ($self, $from, $to) = @_;
330              
331 2         20 ($from, $to) = $self->_process_bounds($from, $to);
332              
333 2         32 my $output_ft = File::Temp->new();
334 2         1019 my @commit_data;
335              
336             $self->_repository_map(sub {
337 2     2   6 my ($repo_path, $db_path, $repository) = @_;
338 2         13 chdir $repo_path;
339 2         167 my ($name, $impl) = _load_and_open_repository($repository);
340 0 0       0 if (not $impl->is_usable()) {
341 0         0 return;
342             }
343 0         0 my $current_branch = $impl->branch_name();
344              
345 0         0 my @branches = @{$impl->branches()};
  0         0  
346 0         0 for my $branch (@branches) {
347 0         0 my ($branch_name, $commit) = @{$branch};
  0         0  
348 0         0 my $branch_db_path = "$db_path/$name/$branch_name";
349 0         0 my @commits = _load_commits($branch_db_path, $from, $to);
350 0 0       0 if (not @commits) {
351 0         0 next;
352             }
353 0         0 print $output_ft "Repository: $name\n".
354             "Branch: $branch_name\n\n";
355 0         0 for my $commit (@commits) {
356 0         0 my ($time, $id) = @{$commit};
  0         0  
357 0         0 $time = $self->_utc_to_tz($time);
358 0         0 $time =~ s/T/ /;
359 0 0       0 if ($impl->has($id)) {
360             print $output_ft "Pulled at: $time\n".
361 0         0 (join '', @{$impl->show($id)}).
  0         0  
362             "\n";
363              
364 0         0 my $content = join '', @{$impl->show_all($id)};
  0         0  
365 0         0 push @commit_data, [$name, $branch_name, $id, $content];
366             } else {
367 0         0 print $output_ft "Pulled at: $time\n".
368             "commit $id\n".
369             "(no longer present in repository)\n\n";
370             }
371             }
372 0         0 print $output_ft "\n";
373             }
374 0         0 $impl->checkout($current_branch);
375 2         27 });
376              
377 1         65 $output_ft->flush();
378              
379 1 50       6 if (not @commit_data) {
380 1         6 return;
381             }
382              
383 0           my $config = $self->{'config'};
384             my $email = Email::MIME->create(
385 0 0         header_str => [ %{$config->{'headers'} || {}} ],
386             parts => [
387             _make_email_mime(slurp($output_ft), 'log.txt'),
388             map {
389 0           my ($name, $branch_name, $id, $content) = @{$_};
  0            
  0            
390 0           _make_email_mime($content,
391             "$name-$branch_name-$id.diff"),
392             } @commit_data
393             ]
394             );
395              
396 0           return $email;
397             }
398              
399             1;
400              
401             __END__
402              
403             =head1 NAME
404              
405             App::SCM::Digest
406              
407             =head1 SYNOPSIS
408              
409             my $digest = App::SCM::Digest->new($config);
410             $digest->update();
411             $digest->get_email();
412              
413             =head1 DESCRIPTION
414              
415             Provides for sending source control management (SCM) repository commit
416             digest emails. It does this based on the time when the commit was
417             pulled into the local repository, rather than when the commit was
418             committed, so that for a particular time period, the relevant set of
419             commits remains the same.
420              
421             =head1 CONFIGURATION
422              
423             The configuration hashref is like so:
424              
425             db_path => "/path/to/db",
426             repository_path => "/path/to/local/repositories",
427             timezone => "local",
428             ignore_errors => 0,
429             headers => {
430             from => "From Address <from@example.org>",
431             to => "To Address <to@example.org>",
432             ...
433             },
434             repositories => [
435             { name => 'test',
436             url => 'http://example.org/path/to/repository',
437             type => ['git'|'hg'] },
438             { name => 'local-test',
439             url => 'file:///path/to/repository',
440             type => ['git'|'hg'] },
441             ...
442             ]
443              
444             The commit pull times for each of the repositories are stored in
445             C<db_path>, which must be a directory.
446              
447             The local copies of the repositories are stored in C<repository_path>,
448             which must also be a directory.
449              
450             The C<timezone> entry is optional, and defaults to 'UTC'. It must be
451             a valid constructor value for L<DateTime::TimeZone>. See
452             L<DateTime::TimeZone::Catalog> for a list of valid options.
453              
454             C<ignore_errors> is an optional boolean, and defaults to false. If
455             false, errors will cause methods to die immediately. If true, errors
456             will instead be printed to C<stderr>, and the method will continue
457             onto the next repository.
458              
459             L<App::SCM::Digest> clones local copies of the repositories into the
460             C<repository_path> directory. These local copies should not be used
461             except by L<App::SCM::Digest>.
462              
463             =head1 CONSTRUCTOR
464              
465             =over 4
466              
467             =item B<new>
468              
469             Takes a configuration hashref, as per L<CONFIGURATION> as its single
470             argument. Returns a new instance of L<App::SCM::Digest>.
471              
472             =back
473              
474             =head1 PUBLIC METHODS
475              
476             =over 4
477              
478             =item B<update>
479              
480             Initialises and updates the local commit databases for each
481             repository-branch pair. These databases record the time at which each
482             commit was received.
483              
484             When initialising a particular database, only the latest commit is
485             stored. Subsequent updates record all subsequent commits.
486              
487             =item B<get_email>
488              
489             Takes two date strings with the format '%Y-%m-%dT%H:%M:%S',
490             representing the lower and upper bounds of a time period, as its
491             arguments. Returns an L<Email::MIME> object containing all of the
492             commits pulled within that time period, using the details from the
493             C<headers> entry in the configuration to construct the email.
494              
495             =back
496              
497             =head1 AUTHOR
498              
499             Tom Harrison, C<< <tomhrr at cpan.org> >>
500              
501             =head1 COPYRIGHT AND LICENCE
502              
503             Copyright (C) 2015 Tom Harrison
504              
505             This library is free software; you can redistribute it and/or modify
506             it under the same terms as Perl itself, either Perl version 5.8.8 or,
507             at your option, any later version of Perl 5 you may have available.
508              
509             =cut