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   450415 use strict;
  7         69  
  7         173  
4 7     7   31 use warnings;
  7         11  
  7         181  
5              
6 7     7   1598 use App::SCM::Digest::Utils qw(system_ad slurp);
  7         18  
  7         334  
7 7     7   1817 use App::SCM::Digest::SCM::Factory;
  7         21  
  7         193  
8              
9 7     7   45 use autodie;
  7         10  
  7         26  
10 7     7   33765 use DateTime;
  7         2661289  
  7         318  
11 7     7   3284 use DateTime::Format::Strptime;
  7         343566  
  7         36  
12 7     7   4255 use Getopt::Long;
  7         53989  
  7         28  
13 7     7   3348 use Email::MIME;
  7         154547  
  7         194  
14 7     7   1953 use File::Copy;
  7         11826  
  7         352  
15 7     7   46 use File::Path;
  7         17  
  7         335  
16 7     7   1772 use File::ReadBackwards;
  7         6397  
  7         204  
17 7     7   41 use File::Temp qw(tempdir);
  7         13  
  7         448  
18 7     7   45 use List::Util qw(first);
  7         14  
  7         341  
19 7     7   39 use POSIX qw();
  7         10  
  7         116  
20              
21 7     7   28 use constant PATTERN => '%FT%T';
  7         10  
  7         478  
22 7         15342 use constant EMAIL_ATTRIBUTES => (
23             content_type => 'text/plain',
24             disposition => 'attachment',
25             charset => 'UTF-8',
26             encoding => 'quoted-printable',
27 7     7   36 );
  7         11  
28              
29             our $VERSION = '0.12';
30              
31             sub new
32             {
33 2     2 1 1843 my ($class, $config) = @_;
34 2         13 my $self = { config => $config };
35 2         9 bless $self, $class;
36 2         10 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   22 my ($name) = @_;
49              
50 12         135 return App::SCM::Digest::SCM::Factory->new($name);
51             }
52              
53             sub _load_repository
54             {
55 12     12   30 my ($repository) = @_;
56              
57 12         22 my ($name, $url, $type) = @{$repository}{qw(name url type)};
  12         45  
58 12         32 my $impl = _impl($type);
59              
60 12         117 return ($name, $impl);
61             }
62              
63             sub _load_and_open_repository
64             {
65 4     4   14 my ($repository) = @_;
66              
67 4         20 my ($name, $impl) = _load_repository($repository);
68 4         37 eval { $impl->open_repository($name) };
  4         44  
69 4 100       6429 if (my $error = $@) {
70 3         424 die "Unable to open repository '$name': $error";
71             }
72              
73 1         5 return ($name, $impl);
74             }
75              
76             sub _init_repository
77             {
78 3     3   13 my ($repo_path, $db_path, $repository) = @_;
79              
80 3         26 chdir $repo_path;
81 3         3357 my ($name, $impl) = _load_repository($repository);
82 3         47 my $pre_existing = (-e $name);
83 3 50       23 if (not $pre_existing) {
84 3 100       51 if (not -e "$db_path/$name") {
85 2         23 mkdir "$db_path/$name";
86             }
87 3         2024 $impl->clone($repository->{'url'}, $name);
88             }
89 1         81 $impl->open_repository($name);
90 1 50       7 if (not $impl->is_usable()) {
91 1         17 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   10 my ($repo_path, $db_path, $repository) = @_;
120              
121 2         18 chdir $repo_path;
122 2         159 my ($name, $impl) = _load_and_open_repository($repository);
123 1 50       6 if (not $impl->is_usable()) {
124 1         16 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   23 my ($self, $method) = @_;
166              
167 7         28 my $config = $self->{'config'};
168              
169             my ($repo_path, $db_path, $repositories) =
170 7         17 @{$config}{qw(repository_path db_path repositories)};
  7         33  
171              
172 7         17 for my $repository (@{$repositories}) {
  7         28  
173 7         18 eval {
174 7         26 $method->($repo_path, $db_path, $repository);
175             };
176 7 100       2023 if (my $error = $@) {
177 5         40 chdir $repo_path;
178 5         388 my ($name, $impl) = _load_repository($repository);
179 5         66 my $backup_dir = tempdir(CLEANUP => 1);
180 5         2379 my $backup_path = $backup_dir.'/temporary';
181 5         25 my $do_backup = (-e $name);
182 5 50       23 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         15 eval {
189 5         42 $impl->clone($repository->{'url'}, $name);
190 0         0 $method->($repo_path, $db_path, $repository);
191             };
192 5 50       2960 if (my $sub_error = $@) {
193 5 50       21 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         54 my $error_msg = "Re-clone or nested operation failed: ".
211             "$sub_error (original error was $error)";
212 5 100       19 if ($config->{'ignore_errors'}) {
213 3         69 warn $error_msg;
214             } else {
215 2         41 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 1622 my ($self) = @_;
227              
228 3         24 $self->_repository_map(\&_init_repository);
229 2         35 $self->_repository_map(\&_update_repository);
230              
231 2         46 return 1;
232             }
233              
234             sub _process_bounds
235             {
236 2     2   8 my ($self, $from, $to) = @_;
237              
238 2         4 my $config = $self->{'config'};
239 2   50     19 my $tz = $config->{'timezone'} || 'UTC';
240              
241 2 50 33     18 if (not defined $from and not defined $to) {
    0          
    0          
242 2         28 $from = DateTime->now(time_zone => $tz)
243             ->subtract(days => 1)
244             ->strftime(PATTERN);
245 2         3757 $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         734 my $strp =
254             DateTime::Format::Strptime->new(pattern => PATTERN,
255             time_zone => $tz);
256              
257             my ($from_dt, $to_dt) =
258 2         3551 map { $strp->parse_datetime($_) }
  4         1561  
259             ($from, $to);
260 2 50       1247 if (not $from_dt) {
261 0         0 die "Invalid 'from' time provided.";
262             }
263 2 50       17 if (not $to_dt) {
264 0         0 die "Invalid 'to' time provided.";
265             }
266              
267             ($from, $to) =
268 2         11 map { $_->set_time_zone('UTC');
  4         157  
269 4         44 $_->strftime(PATTERN) }
270             ($from_dt, $to_dt);
271              
272 2         190 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 1756 my ($self, $from, $to) = @_;
330              
331 2         10 ($from, $to) = $self->_process_bounds($from, $to);
332              
333 2         24 my $output_ft = File::Temp->new();
334 2         812 my @commit_data;
335              
336             $self->_repository_map(sub {
337 2     2   8 my ($repo_path, $db_path, $repository) = @_;
338 2         16 chdir $repo_path;
339 2         152 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         33 });
376              
377 1         65 $output_ft->flush();
378              
379 1 50       8 if (not @commit_data) {
380 1         4 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