File Coverage

blib/lib/App/GitHooks/Plugin/NotifyReleasesToSlack.pm
Criterion Covered Total %
statement 33 132 25.0
branch 0 24 0.0
condition 0 12 0.0
subroutine 11 19 57.8
pod 6 6 100.0
total 50 193 25.9


line stmt bran cond sub pod time code
1             package App::GitHooks::Plugin::NotifyReleasesToSlack;
2              
3 1     1   16611 use strict;
  1         1  
  1         25  
4 1     1   3 use warnings;
  1         1  
  1         50  
5              
6 1     1   4 use feature 'state';
  1         4  
  1         88  
7              
8 1     1   4 use base 'App::GitHooks::Plugin';
  1         1  
  1         468  
9              
10             # External dependencies.
11 1     1   724 use CPAN::Changes;
  1         15964  
  1         36  
12 1     1   540 use Data::Dumper;
  1         7051  
  1         74  
13 1     1   716 use JSON qw();
  1         8885  
  1         24  
14 1     1   570 use LWP::UserAgent;
  1         30495  
  1         33  
15 1     1   478 use Log::Any qw($log);
  1         3504  
  1         3  
16 1     1   3602 use Try::Tiny;
  1         1025  
  1         48  
17              
18             # Internal dependencies.
19 1     1   415 use App::GitHooks::Constants qw( :PLUGIN_RETURN_CODES );
  1         3434  
  1         1157  
20              
21             # Uncomment to see debug information.
22             #use Log::Any::Adapter ('Stderr');
23              
24              
25             =head1 NAME
26              
27             App::GitHooks::Plugin::NotifyReleasesToSlack - Notify Slack channels of new releases pushed from a repository.
28              
29              
30             =head1 DESCRIPTION
31              
32             If you maintain a changelog file, and tag your release commits, you can use
33             this plugin to send the release notes to Slack channels.
34              
35             Here is a practical scenario:
36              
37             =over 4
38              
39             =item 1.
40              
41             Install C.
42              
43             =item 2.
44              
45             Set up an incoming webhook in Slack. This should give you a URL to post
46             messages to, with a format similar to
47             C.
48              
49             =item 3.
50              
51             Configure the plugin in your C<.githooksrc> file:
52              
53             [NotifyReleasesToSlack]
54             slack_post_url = ...
55             slack_channels = #releases, #test
56             changelog_path = Changes
57              
58             =item 4.
59              
60             Add release notes in your changelog file:
61              
62             v1.0.0 2015-04-12
63             - Added first feature.
64             - Added second feature.
65              
66             =item 5.
67              
68             Commit your release notes:
69              
70             git commit Changelog -m 'Release version 1.0.0.'
71              
72             =item 6.
73              
74             Tag your release:
75              
76             git tag v1.0.0
77             git push origin v1.0.0
78              
79             =item 7.
80              
81             Watch the notification appear in the corresponding Slack channel(s):
82              
83             release-notes BOT: @channel - Release v1.0.0 of test_repo:
84             - Added first feature.
85             - Added second feature.
86              
87             =back
88              
89             =head1 VERSION
90              
91             Version 1.1.1
92              
93             =cut
94              
95             our $VERSION = '1.1.1';
96              
97              
98             =head1 CONFIGURATION OPTIONS
99              
100             This plugin supports the following options in the C<[NotifyReleasesToSlack]>
101             section of your C<.githooksrc> file.
102              
103             [NotifyReleasesToSlack]
104             slack_post_url = https://hooks.slack.com/services/.../.../...
105             slack_channels = #releases, #test
106             changelog_path = Changes
107             notify_everyone = true
108              
109              
110             =head2 slack_post_url
111              
112             After you set up a new incoming webhook in Slack, check under "Integration
113             settings" for the following information: "Webhook URL", "Send your JSON
114             payloads to this URL". This is the URL you need to set as the value for the
115             C config option.
116              
117             slack_post_url = https://hooks.slack.com/services/.../.../...
118              
119              
120             =head2 slack_channels
121              
122             The comma-separated list of channels to send release notifications to.
123              
124             slack_channels = #releases, #test
125              
126             Don't forget to prefix the channel names with '#'. It may still work without
127             it, but some keywords are reserved by Slack and you may see inconsistent
128             behaviors between channels.
129              
130              
131             =head2 changelog_path
132              
133             The path to the changelog file, relative to the root of the repository.
134              
135             For example, if the changelog file is named C and lives at the root of
136             your repository:
137              
138             changelog_path = Changes
139              
140              
141             =head2 notify_everyone
142              
143             Whether @everyone in the Slack channel(s) should be notified or not. C by
144             default, but can be set to C to simply announce releases in the channel
145             without notification.
146              
147             # Notify @everyone in the channel.
148             notify_everyone = true
149              
150             # Just announce in the channel.
151             notify_everyone = false
152              
153              
154             =head1 METHODS
155              
156             =head2 run_pre_push()
157              
158             Code to execute as part of the pre-push hook.
159              
160             my $plugin_return_code = App::GitHooks::Plugin::NotifyReleasesToSlack->run_pre_push(
161             app => $app,
162             stdin => $stdin,
163             );
164              
165             Arguments:
166              
167             =over 4
168              
169             =item * $app I<(mandatory)>
170              
171             An C object.
172              
173             =item * $stdin I<(mandatory)>
174              
175             The content provided by git on stdin, corresponding to a list of references
176             being pushed.
177              
178             =back
179              
180             =cut
181              
182             sub run_pre_push
183             {
184 0     0 1   my ( $class, %args ) = @_;
185 0           my $app = delete( $args{'app'} );
186 0           my $stdin = delete( $args{'stdin'} );
187 0           my $config = $app->get_config();
188              
189 0           $log->info( 'Entering NotifyReleasesToSlack.' );
190              
191             # Verify that the mandatory config options are present.
192 0           my $config_return = verify_config( $config );
193 0 0         return $config_return
194             if defined( $config_return );
195              
196             # Check if we are pushing any tags.
197 0           my @tags = get_pushed_tags( $app, $stdin );
198 0           $log->infof( "Found %s tag(s) to push.", scalar( @tags ) );
199 0 0         if ( scalar( @tags ) == 0 )
200             {
201 0           $log->info( "No tags were found in the list of references to push." );
202 0           return $PLUGIN_RETURN_SKIPPED;
203             }
204              
205             # Get the list of releases in the changelog.
206 0           my $releases = get_changelog_releases( $app );
207 0           $log->infof( "Found %s release(s) in the changelog file.", scalar( keys %$releases ) );
208              
209             # Determine the name of the repository.
210 0           my $remote_name = get_remote_name( $app );
211 0           $log->infof( "The repository's remote name is %s.", $remote_name );
212              
213             # Determine if we should just announce releases in a normal message, or
214             # notify everyone in each channel.
215 0           my $notify_everyone = $config->get( 'NotifyReleasesToSlack', 'notify_everyone' );
216 0 0 0       $notify_everyone = defined( $notify_everyone ) && ( $notify_everyone eq 'false' )
217             ? 0
218             : 1;
219              
220             # Analyze tags.
221 0           foreach my $tag ( @tags )
222             {
223             # Check if there's an entry in the changelog.
224 0           my $release = $releases->{ $tag };
225 0 0         if ( !defined( $release ) )
226             {
227 0           $log->infof( "No release found in the changelog for tag '%s'.", $tag );
228 0           next;
229             }
230 0           $log->infof( "Found release notes for %s.", $tag );
231              
232             # Serialize release notes.
233             my $serialized_notes = join(
234             "\n",
235 0           map { $_->serialize() } $release->group_values()
  0            
236             );
237              
238             # Notify Slack.
239 0 0         notify_slack(
240             $app,
241             sprintf(
242             "*%sRelease %s of %s:*\n%s",
243             $notify_everyone
244             ? ' - '
245             : '',
246             $tag,
247             $remote_name,
248             $serialized_notes,
249             ),
250             );
251             }
252              
253 0           return $PLUGIN_RETURN_PASSED;
254             }
255              
256              
257             =head1 FUNCTIONS
258              
259             =head2 verify_config()
260              
261             Verify that the mandatory options are defined in the current githooksrc config.
262              
263             my $plugin_return_code = App::GitHooks::Plugin::NotifyReleasesToSlack::verify_config(
264             $config
265             );
266              
267             Arguments:
268              
269             =over 4
270              
271             =item * $config I<(mandatory)>
272              
273             An C object.
274              
275             =back
276              
277             =cut
278              
279             sub verify_config
280             {
281 0     0 1   my ( $config ) = @_;
282              
283             # Check if a Slack post url is defined in the config.
284 0           my $slack_post_url = $config->get( 'NotifyReleasesToSlack', 'slack_post_url' );
285 0 0         if ( !defined( $slack_post_url ) )
286             {
287 0           $log->info('No Slack post URL defined in the [NotifyReleasesToSlack] section, skipping plugin.');
288 0           return $PLUGIN_RETURN_SKIPPED;
289             }
290              
291             # Check if Slack channels are defined in the config.
292 0           my $slack_channels = $config->get( 'NotifyReleasesToSlack', 'slack_channels' );
293 0 0         if ( !defined( $slack_channels ) )
294             {
295 0           $log->info('No Slack channels to post to defined in the [NotifyReleasesToSlack] section, skipping plugin.');
296 0           return $PLUGIN_RETURN_SKIPPED;
297             }
298              
299             # Check if a changelog is defined in the config.
300 0           my $changelog_path = $config->get( 'NotifyReleasesToSlack', 'changelog_path' );
301 0 0         if ( !defined( $changelog_path ) )
302             {
303 0           $log->info( "'changelog_path' is not defined in the [NotifyReleasesToSlack] section of your .githooksrc config." );
304 0           return $PLUGIN_RETURN_SKIPPED;
305             }
306              
307             # If notify_everyone is set, make sure the value is valid.
308 0           my $notify_everyone = $config->get( 'NotifyReleasesToSlack', 'notify_everyone' );
309 0 0 0       if ( defined( $notify_everyone ) && ( $notify_everyone !~ /(?:true|false)/ ) )
310             {
311 0           my $error = "'notify_everyone' is defined in [NotifyReleasesToSlack] but the value is not valid";
312 0           $log->error( "$error." );
313 0           die "$error\n";
314             }
315              
316 0           return undef;
317             }
318              
319              
320             =head2 get_remote_name()
321              
322             Get the name of the repository.
323              
324             my $remote_name = App::GitHooks::Plugin::NotifyReleasesToSlack::get_remote_name(
325             $app
326             );
327              
328             Arguments:
329              
330             =over 4
331              
332             =item * $app I<(mandatory)>
333              
334             An C object.
335              
336             =back
337              
338             =cut
339              
340             sub get_remote_name
341             {
342 0     0 1   my ( $app ) = @_;
343 0           my $repository = $app->get_repository();
344              
345             # Retrieve the remote path.
346 0           $log->info('run git');
347 0   0       my $remote = $repository->run( qw( config --get remote.origin.url ) ) // '';
348 0           $log->info('run git');
349              
350             # Extract the remote name.
351 0           my ( $remote_name ) = ( $remote =~ /\/(.*?)\.git$/i );
352 0   0       $remote_name //= '(no remote found)';
353 0           $log->info('run git');
354              
355 0           return $remote_name;
356             }
357              
358              
359             =head2 notify_slack()
360              
361             Display a notification in the Slack channels defined in the config file.
362              
363             App::GitHooks::Plugin::NotifyReleasesToSlack::notify_slack(
364             $app,
365             $message,
366             );
367              
368             Arguments:
369              
370             =over 4
371              
372             =item * $app I<(mandatory)>
373              
374             An C object.
375              
376             =item * $message I<(mandatory)>
377              
378             The message to display in Slack channels.
379              
380             =back
381              
382             =cut
383              
384             sub notify_slack
385             {
386 0     0 1   my ( $app, $message ) = @_;
387 0           my $config = $app->get_config();
388              
389             # Get the list of channels to notify
390 0           state $slack_channels =
391             [
392             split(
393             /\s*,\s*/,
394             $config->get( 'NotifyReleasesToSlack', 'slack_channels' )
395             )
396             ];
397              
398             # Notify Slack channels.
399 0           foreach my $channel ( @$slack_channels )
400             {
401 0           $log->infof( 'Notifying Slack channel %s: %s', $channel, $message );
402              
403             # Prepare payload for the request.
404 0           my $request_payload =
405             JSON::encode_json(
406             {
407             text => $message,
408             channel => $channel,
409             }
410             );
411              
412             # Prepare request.
413 0           my $request = HTTP::Request->new(
414             POST => $config->get( 'NotifyReleasesToSlack', 'slack_post_url' ),
415             );
416 0           $request->content( $request_payload );
417              
418             # Send request to Slack.
419 0           my $user_agent = LWP::UserAgent->new();
420 0           my $response = $user_agent->request( $request );
421              
422             # If the connection is down, or Slack is down, warn the user.
423 0 0         if ( !$response->is_success() )
424             {
425 0           my $error = sprintf(
426             "Failed to notify channel '%s' with message '%s'.\n>>> %s %s.",
427             $channel,
428             $message,
429             $response->code(),
430             $response->message(),
431             );
432              
433             # Notify any logging systems.
434 0           $log->error( $error );
435              
436             # Notify the user who is pushing tags.
437 0           print STDERR "$error\n";
438             }
439             }
440              
441 0           return;
442             }
443              
444              
445             =head2 get_changelog_releases()
446              
447             Retrieve a hashref of all the releases in the changelog file.
448              
449             my $releases = App::GitHooks::Plugin::NotifyReleasesToSlack::get_changelog_releases(
450             $app
451             );
452              
453             Arguments:
454              
455             =over 4
456              
457             =item * $app I<(mandatory)>
458              
459             An C object.
460              
461             =back
462              
463             =cut
464              
465             sub get_changelog_releases
466             {
467 0     0 1   my ( $app ) = @_;
468 0           my $repository = $app->get_repository();
469 0           my $config = $app->get_config();
470              
471             # Make sure the changelog file exists.
472 0           my $changelog_path = $config->get( 'NotifyReleasesToSlack', 'changelog_path' );
473 0           $changelog_path = $repository->work_tree() . '/' . $changelog_path;
474 0 0         die "The changelog '$changelog_path' specified in your .githooksrc config does not exist in the repository\n"
475             if ! -e $changelog_path;
476 0           $log->infof( "Using changelog '%s'.", $changelog_path );
477              
478             # Read the changelog.
479             my $changes =
480             try
481             {
482 0     0     return CPAN::Changes->load( $changelog_path );
483             }
484             catch
485             {
486 0     0     $log->error( "Unable to parse the change log" );
487 0           die "Unable to parse the change log\n";
488 0           };
489 0           $log->info( 'Successfully parsed the change log file.' );
490              
491             # Organize the releases into a hash for easy lookup.
492             my $releases =
493             {
494 0           map { $_->version() => $_ }
  0            
495             $changes->releases()
496             };
497              
498 0           return $releases;
499             }
500              
501              
502             =head2 get_pushed_tags()
503              
504             Retrieve a list of the tags being pushed with C.
505              
506             my @tags = App::GitHooks::Plugin::NotifyReleasesToSlack::get_pushed_tags(
507             $app,
508             $stdin,
509             );
510              
511             Arguments:
512              
513             =over 4
514              
515             =item * $app I<(mandatory)>
516              
517             An C object.
518              
519             =item * $stdin I<(mandatory)>
520              
521             The content provided by git on stdin, corresponding to a list of references
522             being pushed.
523              
524             =back
525              
526             =cut
527              
528             sub get_pushed_tags
529             {
530 0     0 1   my ( $app, $stdin ) = @_;
531 0           my $config = $app->get_config();
532              
533             # Tag pattern.
534 0   0       my $git_tag_regex = $config->get_regex( 'NotifyReleasesToSlack', 'git_tag_regex' )
535             // '(v\d+\.\d+\.\d+)';
536 0           $log->infof( "Using git tag regex '%s'.", $git_tag_regex );
537              
538             # Analyze each reference being pushed.
539 0           my $tags = {};
540 0           foreach my $line ( @$stdin )
541             {
542 0           chomp( $line );
543 0           $log->debugf( 'Parse STDIN line >%s<.', $line );
544              
545             # Extract the tag information.
546 0           my ( $tag ) = ( $line =~ /^refs\/tags\/$git_tag_regex\b/x );
547 0 0         next if !defined( $tag );
548 0           $log->infof( "Found tag '%s'.", $tag );
549 0           $tags->{ $tag } = 1;
550             }
551              
552 0           return keys %$tags;
553             }
554              
555              
556             =head1 BUGS
557              
558             Please report any bugs or feature requests through the web interface at
559             L.
560             I will be notified, and then you'll automatically be notified of progress on
561             your bug as I make changes.
562              
563              
564             =head1 SUPPORT
565              
566             You can find documentation for this module with the perldoc command.
567              
568             perldoc App::GitHooks::Plugin::NotifyReleasesToSlack
569              
570              
571             You can also look for information at:
572              
573             =over
574              
575             =item * GitHub's request tracker
576              
577             L
578              
579             =item * AnnoCPAN: Annotated CPAN documentation
580              
581             L
582              
583             =item * CPAN Ratings
584              
585             L
586              
587             =item * MetaCPAN
588              
589             L
590              
591             =back
592              
593              
594             =head1 AUTHOR
595              
596             L,
597             C<< >>.
598              
599              
600             =head1 COPYRIGHT & LICENSE
601              
602             Copyright 2013-2016 Guillaume Aubert.
603              
604             This code is free software; you can redistribute it and/or modify it under the
605             same terms as Perl 5 itself.
606              
607             This program is distributed in the hope that it will be useful, but WITHOUT ANY
608             WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
609             PARTICULAR PURPOSE. See the LICENSE file for more details.
610              
611             =cut
612              
613             1;