File Coverage

blib/lib/Dist/Zilla/Plugin/ChangelogFromGit/CPAN/Changes.pm
Criterion Covered Total %
statement 153 178 85.9
branch 44 76 57.8
condition 6 9 66.6
subroutine 18 18 100.0
pod 1 3 33.3
total 222 284 78.1


line stmt bran cond sub pod time code
1             package Dist::Zilla::Plugin::ChangelogFromGit::CPAN::Changes 0.230680;
2              
3             # ABSTRACT: Generate valid CPAN::Changes Changelogs from git
4              
5 2     2   8287797 use v5.24;
  2         10  
6 2     2   11 use Moose;
  2         5  
  2         18  
7 2     2   12877 use Moose::Util::TypeConstraints;
  2         10  
  2         20  
8 2     2   3905 use Class::Load 'try_load_class';
  2         4  
  2         168  
9 2     2   15 use CPAN::Changes::Release;
  2         4  
  2         153  
10 2     2   39 use CPAN::Changes 0.400002;
  2         57  
  2         65  
11 2     2   1705 use DateTime;
  2         911947  
  2         108  
12 2     2   22 use Encode;
  2         5  
  2         297  
13 2     2   987 use Git::Wrapper;
  2         27421  
  2         4549  
14              
15             with qw/
16             Dist::Zilla::Role::AfterBuild
17             Dist::Zilla::Role::FileGatherer
18             Dist::Zilla::Role::Git::Repo
19             /;
20              
21             subtype 'CoercedRegexpRef' => as 'RegexpRef';
22              
23             coerce 'CoercedRegexpRef' => from 'Str' => via {qr/$_[0]/};
24              
25              
26             has group_by_author => ( is => 'ro', isa => 'Bool', default => 0);
27              
28              
29             has show_author_email => ( is => 'ro', isa => 'Bool', default => 0);
30              
31              
32             has show_author => ( is => 'ro', isa => 'Bool', default => 1);
33              
34              
35             has tag_regexp => (
36             is => 'ro',
37             isa => 'CoercedRegexpRef',
38             coerce => 1,
39             default => 'v?(\d+\.\d+(_\d+)?)'
40             );
41              
42              
43             has file_name => (is => 'ro', isa => 'Str', default => 'Changes');
44              
45              
46             has preamble => (
47             is => 'ro',
48             lazy => 1,
49             isa => 'Str',
50             default => sub { 'Changelog for ' . $_[0]->zilla->name },
51             );
52              
53              
54             has copy_to_root => (is => 'ro', isa => 'Bool', default => 1);
55              
56              
57             has edit_changelog => (is => 'ro', isa => 'Bool', default => 0);
58              
59             has _changes => (is => 'ro', lazy_build => 1, isa => 'CPAN::Changes');
60             has _last_release => (is => 'ro', lazy_build => 1, isa => 'Maybe[version]');
61             has _tags => (is => 'rw', isa => 'ArrayRef', default => sub {[]});
62              
63             has _git => (
64             is => 'ro',
65             lazy => 1,
66             isa => 'Git::Wrapper',
67             default => sub { Git::Wrapper->new('.') },
68             );
69              
70             has _git_can_mailmap => (
71             is => 'ro',
72             lazy => 1,
73             isa => 'Bool',
74             default => sub {
75             my ($gv) = $_[0]->git->version =~ /(\d+\.\d+\.\d+)/;
76             $gv //= 0;
77             return version->parse($gv) < '1.8.2' ? 0 : 1;
78             },
79             );
80              
81             sub BUILDARGS {
82 7     7 1 42 my %args = %{$_[1]};
  7         92  
83              
84 7 50       76 if (exists $args{tag_regexp}) {
85 0 0       0 if ($args{tag_regexp} eq 'semantic') {
    0          
86 0         0 $args{tag_regexp} = 'v?(\d+\.\d+\.\d+)';
87             } elsif ($args{tag_regexp} eq 'decimal') {
88 0         0 $args{tag_regexp} = 'v?(\d+\.\d+)$';
89             }
90             }
91              
92 7         280 return \%args;
93             }
94              
95             sub _build__changes {
96 7     7   36 my $self = shift;
97              
98 7         17 my $changes;
99 7         314 my @args = (preamble => $self->preamble);
100              
101 7 100       300 if (-f $self->file_name) {
102 1         41 $self->logger->log_debug('Starting from an existing changelog');
103 1         114 $changes = CPAN::Changes->load($self->file_name, @args);
104             } else {
105 6         229 $self->logger->log_debug('Creating full changelog');
106              
107             # TODO maybe. If Changelog is new and this is the first release,
108             # first entry should just be "First release"
109 6         567 $changes = CPAN::Changes->new(@args);
110             }
111              
112 7         1487 return $changes;
113             }
114              
115             sub _build__last_release {
116 6     6   26 my $self = shift;
117              
118 6         286 my @releases = $self->_changes->releases;
119 6 50       301 if (scalar @releases > 1) {
120 0         0 my $last_release = version->parse($releases[-1]->version);
121 0         0 $self->logger->log("Last release in changelog: $last_release");
122              
123 0 0       0 if (version->parse($self->zilla->version) == $last_release) {
124 0         0 $last_release = $releases[-2]->version;
125 0         0 $self->logger->log(
126             "Last release is *this* release, using $last_release as last");
127             }
128 0         0 $last_release =~ $self->tag_regexp;
129 0 0       0 if (!defined $1) {
130 0         0 $self->logger->log_fatal(
131             "Last release $last_release does not match tag_regexp");
132             }
133 0         0 return version->parse($1);
134             }
135 6         313 return;
136             }
137              
138             sub gather_files {
139 7     7 0 333915 my $self = shift;
140              
141 7 100       61 if (!$ENV{DZIL_RELEASING}) {
142 1         8 $self->log(
143             'We are not performing a release, so not wasting time updating changelog'
144             );
145              
146 1         344 $self->add_file(
147             Dist::Zilla::File::InMemory->new({
148             content => $self->_changes->serialize,
149             name => $self->file_name,
150             }));
151              
152 1         705 return;
153             }
154              
155 6         54 $self->_get_tags;
156 6         80 $self->_get_changes;
157              
158 6         270 my $content = $self->_changes->serialize;
159              
160 6 50       11097 $self->log('Editing changelogs is disabled') if $ENV{NO_EDIT_CHANGES};
161              
162 6 50 33     480 if ($self->edit_changelog && !$ENV{NO_EDIT_CHANGES}) {
163 0 0       0 if (try_load_class('Proc::InvokeEditor')) {
164 0         0 my $edited_content = Proc::InvokeEditor->edit($content);
165 0         0 my $new_changes = CPAN::Changes->load_string($edited_content);
166 0         0 $content = $new_changes->serialize;
167             } else {
168 0         0 $self->log(
169             'Proc::InvokeEditor needs to be installed for editing changelogs'
170             );
171             }
172             }
173              
174 6         439 my $file = Dist::Zilla::File::InMemory->new({
175             content => $content,
176             name => $self->file_name,
177             });
178              
179 6         4353 $self->add_file($file);
180             }
181              
182             # Will copy the the changelog into the root folder if C<copy_to_root> is enabled.
183             sub after_build {
184 7     7 0 526994 my ($self, $args) = @_;
185              
186 7 100       118 return unless $ENV{DZIL_RELEASING};
187 6 50       357 return unless $self->copy_to_root;
188              
189 6         247 my $build_file = $args->{build_root}->child($self->file_name);
190              
191 6         409 my $root_file = $self->zilla->root->child($self->file_name);
192 6         213 $self->log_debug("Copying changes file from $build_file to $root_file");
193 6 50       577 if (!-e $build_file) {
194 0         0 $self->logger->log_fatal("Where is the changelog?");
195             }
196 6         185 $build_file->copy($root_file);
197              
198 6         63317 return;
199             }
200              
201             sub _get_tags {
202 6     6   23 my $self = shift;
203 6         220 $self->logger->log_debug(
204             'Searching for tags matching ' . $self->tag_regexp);
205 6         83 my @tags;
206 6         244 foreach my $tag ($self->_git->RUN('tag')) {
207 15 50       58659 next unless $tag =~ $self->tag_regexp;
208 15         576 push @tags, [ version->parse($1), $tag ];
209             }
210              
211 6         9116 @{$self->_tags} = map { $_->[1] } sort { $a->[0] <=> $b->[0] } @tags;
  6         373  
  15         44  
  15         217  
212 6         22 push @{$self->_tags}, 'HEAD';
  6         228  
213 6         63 return;
214             }
215              
216             sub _git_log {
217 21     21   84 my ($self, $revs) = @_;
218              
219             # easier to read than just writing the string directly
220 21         241 my $format = {
221             author => '%aN',
222             date => '%at',
223             email => '<%aE>',
224             subject => '%s',
225             };
226              
227             # commit has to come first
228 21         105 my $format_str = 'commit:%H%n';
229 21         137 while (my ($attr, $esc) = each %$format) {
230 84         347 $format_str .= "$attr:$esc%n";
231             }
232 21         52 $format_str .= '<END COMMIT>%n';
233              
234 21         214 my $log_opts = {
235             no_color => 1,
236             format => $format_str,
237             };
238              
239 21 100 66     1286 if ($self->_git_can_mailmap and $self->show_author) {
240 17         543 $self->logger->log_debug('Using git mailmap');
241 17         854 $log_opts->{'use-mailmap'} = 1;
242             }
243              
244 21         952 my @out = $self->_git->RUN(
245             log => $log_opts,
246             $revs
247             );
248              
249 21         233050 my $commits;
250             my $cur_commit;
251 21         210 while (my $line = shift @out) {
252 162 100       373 if ($line eq '<END COMMIT>') {
253 27         58 $cur_commit = undef;
254 27         180 shift @out;
255 27         91 next;
256             }
257              
258 135 100       569 if ($line =~ /^commit:(\w+)$/) {
    50          
259 27         243 $cur_commit = $1;
260 27         472 $commits->{$cur_commit}->{id} = $cur_commit;
261 27         125 next;
262             } elsif (!$cur_commit) {
263 0         0 die 'Failed to parse commit id';
264             }
265              
266 108 50       394 if ($line =~ /^(\w+):(.+)$/) {
267 108 50       291 die 'WTF? Not currently in a commit?' if !$cur_commit;
268 108         883 $commits->{$cur_commit}->{$1} = $2;
269 108         337 next;
270             }
271             }
272              
273 21 100       120 if (!defined $commits) {
274 5         536 $self->logger->log_debug("No commits found for $revs");
275             return [{
276 5         1035 subject => "No changes found"
277             }];
278             }
279              
280 16         475 return [sort { $b->{date} <=> $a->{date} } values %$commits];
  14         162  
281             }
282              
283             sub _get_release_date {
284 21     21   104 my ($self, $tag) = @_;
285              
286             # TODO configurable date formats
287 21 100       103 if ($tag eq 'HEAD') {
288 6         149 return DateTime->now->iso8601;
289             }
290              
291             # XXX 'max-count' => '1' doesn't work with Git::Wrapper, it becomes
292             # just '--max-count'. File a bug!
293 15         1692 my @out = $self->_git->RUN(log => {format => '%ct', 1 => 1}, $tag);
294 15         157880 my $dt = DateTime->from_epoch(epoch => $out[0]);
295 15         11827 return $dt->iso8601;
296             }
297              
298             sub _get_changes {
299 6     6   29 my $self = shift;
300 6         300 my $last_tag = $self->_last_release;
301              
302 6         28 foreach my $tag (@{$self->_tags}) {
  6         244  
303 21 100       966 my $rev = $last_tag ? "$last_tag..$tag" : $tag;
304 21         63 $last_tag = $tag;
305              
306 21         65 my $version;
307 21 100       74 if ($tag eq 'HEAD') {
308 6         259 $version = $self->zilla->version;
309             } else {
310 15         654 $tag =~ $self->tag_regexp;
311 15 50       76 if (!$1) {
312 0         0 die sprintf
313             'Failed to get a match from tag_regexp: [%s] vs [%s]',
314             $version, $self->tag_regexp;
315             }
316 15         86 $version = $1;
317             }
318              
319 21         8731 $self->logger->log_debug("Tag $tag == Version $version");
320 21 50       2625 if ($self->_last_release) {
321 0 0       0 if ($self->_last_release > version->parse($version)) {
    0          
322 0         0 $self->logger->log_debug("Skipping previous release $version");
323 0         0 next;
324             } elsif ($self->_last_release == version->parse($version)) {
325 0         0 $self->logger->log_debug("Skipping release $version");
326 0         0 next;
327              
328             }
329             }
330              
331 21         644 $self->logger->log_debug("Getting commits for $rev");
332 21         641 my $commits = $self->_git_log($rev);
333              
334 21         222 my $release = CPAN::Changes::Release->new(
335             version => $version,
336             date => $self->_get_release_date($tag),
337             );
338              
339 21         6908 my %seen;
340 21         140 foreach my $commit (@$commits) {
341              
342             # TODO strip extra spaces and newlines
343             # TODO convert * lists
344              
345             # weed out dupes
346 32         747 chomp $commit->{subject};
347 32 50       211 next if exists $seen{$commit->{subject}};
348 32         256 $seen{$commit->{subject}} = 1;
349              
350             # ignore the auto-commits
351 32 50       137 next if $commit->{subject} eq $tag;
352 32 50       253 next if $commit->{subject} =~ /^Release /;
353 32 50       191 next if $commit->{subject} =~ /^Merge (pull|branch)/;
354              
355 32 50       146 unless (utf8::is_utf8($commit->{subject})) {
356 32         739 $commit->{subject} = Encode::decode_utf8($commit->{subject});
357             }
358              
359 32 100 100     2304 if ($self->show_author && exists $commit->{author}) {
360 22         104 my $author = $commit->{author};
361              
362 22 100       1331 if ($self->show_author_email) {
363 10         76 $author .= ' ' . $commit->{email};
364             }
365              
366 22 50       134 unless (utf8::is_utf8($author)) {
367 22         165 $author = Encode::decode_utf8($author);
368             }
369              
370 22 100       1098 if ($self->group_by_author) {
371 10         72 my $group = $author;
372             $release->add_changes({group => $group},
373 10         129 $commit->{subject});
374             } else {
375 12         143 $release->add_changes($commit->{subject} . " ($author)");
376             }
377             } else {
378 10         86 $release->add_changes($commit->{subject});
379             }
380             }
381              
382 21         2696 $self->_changes->add_release($release);
383             }
384              
385 6         261 return;
386             }
387              
388             __PACKAGE__->meta->make_immutable;
389              
390             1;
391              
392             __END__
393              
394             =pod
395              
396             =encoding UTF-8
397              
398             =for :stopwords Ioan Rogers cpan testmatrix url bugtracker rt cpants kwalitee diff irc
399             mailto metadata placeholders metacpan
400              
401             =head1 NAME
402              
403             Dist::Zilla::Plugin::ChangelogFromGit::CPAN::Changes - Generate valid CPAN::Changes Changelogs from git
404              
405             =head1 VERSION
406              
407             version 0.230680
408              
409             =head1 SYNOPSIS
410              
411             [ChangelogFromGit::CPAN::Changes]
412             ; All options from [ChangelogFromGit] plus
413             group_by_author = 1 ; default 0
414             show_author_email = 1 ; default 0
415             show_author = 0 ; default 1
416             edit_changelog = 1 ; default 0
417              
418             =head1 ATTRIBUTES
419              
420             =head2 group_by_author
421              
422             Whether to group commit messages by their author. This is the only way previous
423             versions did it. Defaults to no, and [ Anne Author ] is appended to the commit
424             message.
425              
426             Defaults to off.
427              
428             =head2 show_author_email
429              
430             Author email is probably just noise for most people, but turn this on if you
431             want to show it [ Anne Author <anne@author.com> ]
432              
433             Defaults to off.
434              
435             =head2 show_author
436              
437             Whether to show authors at all. Turning this off also
438             turns off grouping by author and author emails.
439              
440             Defaults to on.
441              
442             =head2 C<tag_regexp>
443              
444             A regexp string which will be used to match git tags to find releases. If your
445             release tags are not compliant with L<CPAN::Changes::Spec>, you can use a
446             capture group. It will be used as the version in place of the full tag name.
447              
448             Also takes C<semantic>, which becomes C<qr{^v?(\d+\.\d+\.\d+)$}>, and
449             C<decimal>, which becomes C<qr{^v?(\d+\.\d+)$}>.
450              
451             Defaults to 'decimal'
452              
453             =head2 C<file_name>
454              
455             The name of the changelog file.
456              
457             Defaults to 'Changes'.
458              
459             =head2 C<preamble>
460              
461             Block of text at the beginning of the changelog.
462              
463             Defaults to 'Changelog for $dist_name'
464              
465             =head2 C<copy_to_root>
466              
467             When true, the generated changelog will be copied into the root folder where it
468             can be committed (possiby automatically by L<Dist::Zilla::Plugin::Git::Commit>)
469              
470             Defaults to true.
471              
472             =head2 C<edit_changelog>
473              
474             When true, the generated changelog will be opened in an editor to allow manual
475             editing.
476              
477             Defaults to false.
478              
479             =head1 SUPPORT
480              
481             =head2 Perldoc
482              
483             You can find documentation for this module with the perldoc command.
484              
485             perldoc Dist::Zilla::Plugin::ChangelogFromGit::CPAN::Changes
486              
487             =head2 Websites
488              
489             The following websites have more information about this module, and may be of help to you. As always,
490             in addition to those websites please use your favorite search engine to discover more resources.
491              
492             =over 4
493              
494             =item *
495              
496             MetaCPAN
497              
498             A modern, open-source CPAN search engine, useful to view POD in HTML format.
499              
500             L<https://metacpan.org/release/Dist-Zilla-Plugin-ChangelogFromGit-CPAN-Changes>
501              
502             =back
503              
504             =head2 Bugs / Feature Requests
505              
506             Please report any bugs or feature requests through the web interface at L<https://github.com/ioanrogers/Dist-Zilla-Plugin-ChangelogFromGit-CPAN-Changes.git/issues>.
507             You will be automatically notified of any progress on the request by the system.
508              
509             =head2 Source Code
510              
511             The source code is available for from the following locations:
512              
513             L<https://github.com/ioanrogers/Dist-Zilla-Plugin-ChangelogFromGit-CPAN-Changes.git>
514              
515             git clone https://github.com/ioanrogers/Dist-Zilla-Plugin-ChangelogFromGit-CPAN-Changes.git.git
516              
517             =head1 AUTHOR
518              
519             Ioan Rogers <ioanr@cpan.org>
520              
521             =head1 COPYRIGHT AND LICENSE
522              
523             This software is copyright (c) 2023 by Ioan Rogers.
524              
525             This is free software; you can redistribute it and/or modify it under
526             the same terms as the Perl 5 programming language system itself.
527              
528             =cut