File Coverage

blib/lib/App/GitHooks/Plugin/NotifyReleasesToSlack.pm
Criterion Covered Total %
statement 31 33 93.9
branch n/a
condition n/a
subroutine 11 11 100.0
pod n/a
total 42 44 95.4


line stmt bran cond sub pod time code
1             package App::GitHooks::Plugin::NotifyReleasesToSlack;
2              
3 1     1   17094 use strict;
  1         1  
  1         33  
4 1     1   3 use warnings;
  1         1  
  1         26  
5              
6 1     1   3 use feature 'state';
  1         5  
  1         77  
7              
8 1     1   4 use base 'App::GitHooks::Plugin';
  1         1  
  1         336  
9              
10             # External dependencies.
11 1     1   573 use CPAN::Changes;
  1         17034  
  1         31  
12 1     1   571 use Data::Dumper;
  1         6480  
  1         51  
13 1     1   604 use JSON qw();
  1         9096  
  1         22  
14 1     1   616 use LWP::UserAgent;
  1         79950  
  1         34  
15 1     1   584 use Log::Any qw($log);
  1         1749  
  1         3  
16 1     1   581 use Try::Tiny;
  1         1191  
  1         53  
17              
18             # Internal dependencies.
19 1     1   273 use App::GitHooks::Constants qw( :PLUGIN_RETURN_CODES );
  0            
  0            
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.0
92              
93             =cut
94              
95             our $VERSION = '1.1.0';
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             my ( $class, %args ) = @_;
185             my $app = delete( $args{'app'} );
186             my $stdin = delete( $args{'stdin'} );
187             my $config = $app->get_config();
188              
189             $log->info( 'Entering NotifyReleasesToSlack.' );
190              
191             # Verify that the mandatory config options are present.
192             my $config_return = verify_config( $config );
193             return $config_return
194             if defined( $config_return );
195              
196             # Check if we are pushing any tags.
197             my @tags = get_pushed_tags( $app, $stdin );
198             $log->infof( "Found %s tag(s) to push.", scalar( @tags ) );
199             if ( scalar( @tags ) == 0 )
200             {
201             $log->info( "No tags were found in the list of references to push." );
202             return $PLUGIN_RETURN_SKIPPED;
203             }
204              
205             # Get the list of releases in the changelog.
206             my $releases = get_changelog_releases( $app );
207             $log->infof( "Found %s release(s) in the changelog file.", scalar( keys %$releases ) );
208              
209             # Determine the name of the repository.
210             my $remote_name = get_remote_name( $app );
211             $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             my $notify_everyone = $config->get( 'NotifyReleasesToSlack', 'notify_everyone' );
216             $notify_everyone = defined( $notify_everyone ) && ( $notify_everyone eq 'false' )
217             ? 0
218             : 1;
219              
220             # Analyze tags.
221             foreach my $tag ( @tags )
222             {
223             # Check if there's an entry in the changelog.
224             my $release = $releases->{ $tag };
225             if ( !defined( $release ) )
226             {
227             $log->infof( "No release found in the changelog for tag '%s'.", $tag );
228             next;
229             }
230             $log->infof( "Found release notes for %s.", $tag );
231              
232             # Serialize release notes.
233             my $serialized_notes = join(
234             "\n",
235             map { $_->serialize() } $release->group_values()
236             );
237              
238             # Notify Slack.
239             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             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             my ( $config ) = @_;
282              
283             # Check if a Slack post url is defined in the config.
284             my $slack_post_url = $config->get( 'NotifyReleasesToSlack', 'slack_post_url' );
285             if ( !defined( $slack_post_url ) )
286             {
287             $log->info('No Slack post URL defined in the [NotifyReleasesToSlack] section, skipping plugin.');
288             return $PLUGIN_RETURN_SKIPPED;
289             }
290              
291             # Check if Slack channels are defined in the config.
292             my $slack_channels = $config->get( 'NotifyReleasesToSlack', 'slack_channels' );
293             if ( !defined( $slack_channels ) )
294             {
295             $log->info('No Slack channels to post to defined in the [NotifyReleasesToSlack] section, skipping plugin.');
296             return $PLUGIN_RETURN_SKIPPED;
297             }
298              
299             # Check if a changelog is defined in the config.
300             my $changelog_path = $config->get( 'NotifyReleasesToSlack', 'changelog_path' );
301             if ( !defined( $changelog_path ) )
302             {
303             $log->info( "'changelog_path' is not defined in the [NotifyReleasesToSlack] section of your .githooksrc config." );
304             return $PLUGIN_RETURN_SKIPPED;
305             }
306              
307             # If notify_everyone is set, make sure the value is valid.
308             my $notify_everyone = $config->get( 'NotifyReleasesToSlack', 'notify_everyone' );
309             if ( defined( $notify_everyone ) && ( $notify_everyone !~ /(?:true|false)/ ) )
310             {
311             my $error = "'notify_everyone' is defined in [NotifyReleasesToSlack] but the value is not valid";
312             $log->error( "$error." );
313             die "$error\n";
314             }
315              
316             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             my ( $app ) = @_;
343             my $repository = $app->get_repository();
344              
345             # Retrieve the remote path.
346             $log->info('run git');
347             my $remote = $repository->run( qw( config --get remote.origin.url ) ) // '';
348             $log->info('run git');
349              
350             # Extract the remote name.
351             my ( $remote_name ) = ( $remote =~ /\/(.*?)\.git$/i );
352             $remote_name //= '(no remote found)';
353             $log->info('run git');
354              
355             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             my ( $app, $message ) = @_;
387             my $config = $app->get_config();
388              
389             # Get the list of channels to notify
390             state $slack_channels =
391             [
392             split(
393             /\s*,\s*/,
394             $config->get( 'NotifyReleasesToSlack', 'slack_channels' )
395             )
396             ];
397              
398             # Notify Slack channels.
399             foreach my $channel ( @$slack_channels )
400             {
401             $log->infof( 'Notifying Slack channel %s: %s', $channel, $message );
402              
403             # Prepare payload for the request.
404             my $request_payload =
405             JSON::encode_json(
406             {
407             text => $message,
408             channel => $channel,
409             }
410             );
411              
412             # Prepare request.
413             my $request = HTTP::Request->new(
414             POST => $config->get( 'NotifyReleasesToSlack', 'slack_post_url' ),
415             );
416             $request->content( $request_payload );
417              
418             # Send request to Slack.
419             my $user_agent = LWP::UserAgent->new();
420             my $response = $user_agent->request( $request );
421              
422             # If the connection is down, or Slack is down, warn the user.
423             if ( !$response->is_success() )
424             {
425             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             $log->error( $error );
435              
436             # Notify the user who is pushing tags.
437             print STDERR "$error\n";
438             }
439             }
440              
441             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             my ( $app ) = @_;
468             my $repository = $app->get_repository();
469             my $config = $app->get_config();
470              
471             # Make sure the changelog file exists.
472             my $changelog_path = $config->get( 'NotifyReleasesToSlack', 'changelog_path' );
473             $changelog_path = $repository->work_tree() . '/' . $changelog_path;
474             die "The changelog '$changelog_path' specified in your .githooksrc config does not exist in the repository\n"
475             if ! -e $changelog_path;
476             $log->infof( "Using changelog '%s'.", $changelog_path );
477              
478             # Read the changelog.
479             my $changes =
480             try
481             {
482             return CPAN::Changes->load( $changelog_path );
483             }
484             catch
485             {
486             $log->error( "Unable to parse the change log" );
487             die "Unable to parse the change log\n";
488             };
489             $log->info( 'Successfully parsed the change log file.' );
490              
491             # Organize the releases into a hash for easy lookup.
492             my $releases =
493             {
494             map { $_->version() => $_ }
495             $changes->releases()
496             };
497              
498             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             my ( $app, $stdin ) = @_;
531             my $config = $app->get_config();
532              
533             # Tag pattern.
534             my $git_tag_regex = $config->get_regex( 'NotifyReleasesToSlack', 'git_tag_regex' )
535             // '(v\d+\.\d+\.\d+)';
536             $log->infof( "Using git tag regex '%s'.", $git_tag_regex );
537              
538             # Analyze each reference being pushed.
539             my $tags = {};
540             foreach my $line ( @$stdin )
541             {
542             chomp( $line );
543             $log->debugf( 'Parse STDIN line >%s<.', $line );
544              
545             # Extract the tag information.
546             my ( $tag ) = ( $line =~ /^refs\/tags\/$git_tag_regex\b/x );
547             next if !defined( $tag );
548             $log->infof( "Found tag '%s'.", $tag );
549             $tags->{ $tag } = 1;
550             }
551              
552             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-2015 Guillaume Aubert.
603              
604             This program is free software: you can redistribute it and/or modify it under
605             the terms of the GNU General Public License version 3 as published by the Free
606             Software Foundation.
607              
608             This program is distributed in the hope that it will be useful, but WITHOUT ANY
609             WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
610             PARTICULAR PURPOSE. See the GNU General Public License for more details.
611              
612             You should have received a copy of the GNU General Public License along with
613             this program. If not, see http://www.gnu.org/licenses/
614              
615             =cut
616              
617             1;