Group
Extension

App-GitHooks-Plugin-NotifyReleasesToSlack/lib/App/GitHooks/Plugin/NotifyReleasesToSlack.pm

package App::GitHooks::Plugin::NotifyReleasesToSlack;

use strict;
use warnings;

use feature 'state';

use base 'App::GitHooks::Plugin';

# External dependencies.
use CPAN::Changes;
use Data::Dumper;
use JSON qw();
use LWP::UserAgent;
use Log::Any qw($log);
use Try::Tiny;

# Internal dependencies.
use App::GitHooks::Constants qw( :PLUGIN_RETURN_CODES );

# Uncomment to see debug information.
#use Log::Any::Adapter ('Stderr');


=head1 NAME

App::GitHooks::Plugin::NotifyReleasesToSlack - Notify Slack channels of new releases pushed from a repository.


=head1 DESCRIPTION

If you maintain a changelog file, and tag your release commits, you can use
this plugin to send the release notes to Slack channels.

Here is a practical scenario:

=over 4

=item 1.

Install C<App::GitHooks::Plugin::NotifyReleasesToSlack>.

=item 2.

Set up an incoming webhook in Slack. This should give you a URL to post
messages to, with a format similar to
C<https://hooks.slack.com/services/.../.../...>.

=item 3.

Configure the plugin in your C<.githooksrc> file:

	[NotifyReleasesToSlack]
	slack_post_url = ...
	slack_channels = #releases, #test
	changelog_path = Changes

=item 4.

Add release notes in your changelog file:

	v1.0.0  2015-04-12
	        - Added first feature.
	        - Added second feature.

=item 5.

Commit your release notes:

	git commit Changelog -m 'Release version 1.0.0.'

=item 6.

Tag your release:

	git tag v1.0.0
	git push origin v1.0.0

=item 7.

Watch the notification appear in the corresponding Slack channel(s):

	release-notes BOT: @channel - Release v1.0.0 of test_repo:
	- Added first feature.
	- Added second feature.

=back

=head1 VERSION

Version 1.1.1

=cut

our $VERSION = '1.1.1';


=head1 CONFIGURATION OPTIONS

This plugin supports the following options in the C<[NotifyReleasesToSlack]>
section of your C<.githooksrc> file.

	[NotifyReleasesToSlack]
	slack_post_url = https://hooks.slack.com/services/.../.../...
	slack_channels = #releases, #test
	changelog_path = Changes
	notify_everyone = true


=head2 slack_post_url

After you set up a new incoming webhook in Slack, check under "Integration
settings" for the following information: "Webhook URL", "Send your JSON
payloads to this URL". This is the URL you need to set as the value for the
C<slack_post_url> config option.

	slack_post_url = https://hooks.slack.com/services/.../.../...


=head2 slack_channels

The comma-separated list of channels to send release notifications to.

	slack_channels = #releases, #test

Don't forget to prefix the channel names with '#'. It may still work without
it, but some keywords are reserved by Slack and you may see inconsistent
behaviors between channels.


=head2 changelog_path

The path to the changelog file, relative to the root of the repository.

For example, if the changelog file is named C<Changes> and lives at the root of
your repository:

	changelog_path = Changes


=head2 notify_everyone

Whether @everyone in the Slack channel(s) should be notified or not. C<true> by
default, but can be set to C<false> to simply announce releases in the channel
without notification.

	# Notify @everyone in the channel.
	notify_everyone = true

	# Just announce in the channel.
	notify_everyone = false


=head1 METHODS

=head2 run_pre_push()

Code to execute as part of the pre-push hook.

  my $plugin_return_code = App::GitHooks::Plugin::NotifyReleasesToSlack->run_pre_push(
		app   => $app,
		stdin => $stdin,
	);

Arguments:

=over 4

=item * $app I<(mandatory)>

An C<App::GitHooks> object.

=item * $stdin I<(mandatory)>

The content provided by git on stdin, corresponding to a list of references
being pushed.

=back

=cut

sub run_pre_push
{
	my ( $class, %args ) = @_;
	my $app = delete( $args{'app'} );
	my $stdin = delete( $args{'stdin'} );
	my $config = $app->get_config();

	$log->info( 'Entering NotifyReleasesToSlack.' );

	# Verify that the mandatory config options are present.
	my $config_return = verify_config( $config );
	return $config_return
		if defined( $config_return );

	# Check if we are pushing any tags.
	my @tags = get_pushed_tags( $app, $stdin );
	$log->infof( "Found %s tag(s) to push.", scalar( @tags ) );
	if ( scalar( @tags ) == 0 )
	{
		$log->info( "No tags were found in the list of references to push." );
		return $PLUGIN_RETURN_SKIPPED;
	}

	# Get the list of releases in the changelog.
	my $releases = get_changelog_releases( $app );
	$log->infof( "Found %s release(s) in the changelog file.", scalar( keys %$releases ) );

	# Determine the name of the repository.
	my $remote_name = get_remote_name( $app );
	$log->infof( "The repository's remote name is %s.", $remote_name );

	# Determine if we should just announce releases in a normal message, or
	# notify everyone in each channel.
	my $notify_everyone = $config->get( 'NotifyReleasesToSlack', 'notify_everyone' );
	$notify_everyone = defined( $notify_everyone ) && ( $notify_everyone eq 'false' )
		? 0
		: 1;

	# Analyze tags.
	foreach my $tag ( @tags )
	{
		# Check if there's an entry in the changelog.
		my $release = $releases->{ $tag };
		if ( !defined( $release ) )
		{
			$log->infof( "No release found in the changelog for tag '%s'.", $tag );
			next;
		}
		$log->infof( "Found release notes for %s.", $tag );

		# Serialize release notes.
		my $serialized_notes = join(
			"\n",
			map { $_->serialize() } $release->group_values()
		);

		# Notify Slack.
		notify_slack(
			$app,
			sprintf(
				"*%sRelease %s of %s:*\n%s",
				$notify_everyone
					? '<!everyone> - '
					: '',
				$tag,
				$remote_name,
				$serialized_notes,
			),
		);
	}

	return $PLUGIN_RETURN_PASSED;
}


=head1 FUNCTIONS

=head2 verify_config()

Verify that the mandatory options are defined in the current githooksrc config.

	my $plugin_return_code = App::GitHooks::Plugin::NotifyReleasesToSlack::verify_config(
		$config
	);

Arguments:

=over 4

=item * $config I<(mandatory)>

An C<App::GitHooks::Config> object.

=back

=cut

sub verify_config
{
	my ( $config ) = @_;

	# Check if a Slack post url is defined in the config.
	my $slack_post_url = $config->get( 'NotifyReleasesToSlack', 'slack_post_url' );
	if ( !defined( $slack_post_url ) )
	{
		$log->info('No Slack post URL defined in the [NotifyReleasesToSlack] section, skipping plugin.');
		return $PLUGIN_RETURN_SKIPPED;
	}

	# Check if Slack channels are defined in the config.
	my $slack_channels = $config->get( 'NotifyReleasesToSlack', 'slack_channels' );
	if ( !defined( $slack_channels ) )
	{
		$log->info('No Slack channels to post to defined in the [NotifyReleasesToSlack] section, skipping plugin.');
		return $PLUGIN_RETURN_SKIPPED;
	}

	# Check if a changelog is defined in the config.
	my $changelog_path = $config->get( 'NotifyReleasesToSlack', 'changelog_path' );
	if ( !defined( $changelog_path ) )
	{
		$log->info( "'changelog_path' is not defined in the [NotifyReleasesToSlack] section of your .githooksrc config." );
		return $PLUGIN_RETURN_SKIPPED;
	}

	# If notify_everyone is set, make sure the value is valid.
	my $notify_everyone = $config->get( 'NotifyReleasesToSlack', 'notify_everyone' );
	if ( defined( $notify_everyone ) && ( $notify_everyone !~ /(?:true|false)/ ) )
	{
		my $error = "'notify_everyone' is defined in [NotifyReleasesToSlack] but the value is not valid";
		$log->error( "$error." );
		die "$error\n";
	}

	return undef;
}


=head2 get_remote_name()

Get the name of the repository.

	my $remote_name = App::GitHooks::Plugin::NotifyReleasesToSlack::get_remote_name(
		$app
	);

Arguments:

=over 4

=item * $app I<(mandatory)>

An C<App::GitHooks> object.

=back

=cut

sub get_remote_name
{
	my ( $app ) = @_;
	my $repository = $app->get_repository();

	# Retrieve the remote path.
	$log->info('run git');
	my $remote = $repository->run( qw( config --get remote.origin.url ) ) // '';
	$log->info('run git');

	# Extract the remote name.
	my ( $remote_name ) = ( $remote =~ /\/(.*?)\.git$/i );
	$remote_name //= '(no remote found)';
	$log->info('run git');

	return $remote_name;
}


=head2 notify_slack()

Display a notification in the Slack channels defined in the config file.

	App::GitHooks::Plugin::NotifyReleasesToSlack::notify_slack(
		$app,
		$message,
	);

Arguments:

=over 4

=item * $app I<(mandatory)>

An C<App::GitHooks> object.

=item * $message I<(mandatory)>

The message to display in Slack channels.

=back

=cut

sub notify_slack
{
	my ( $app, $message ) = @_;
	my $config = $app->get_config();

	# Get the list of channels to notify
	state $slack_channels =
	[
		split(
			/\s*,\s*/,
			$config->get( 'NotifyReleasesToSlack', 'slack_channels' )
		)
	];

	# Notify Slack channels.
	foreach my $channel ( @$slack_channels )
	{
		$log->infof( 'Notifying Slack channel %s: %s', $channel, $message );

		# Prepare payload for the request.
		my $request_payload =
			JSON::encode_json(
				{
					text     => $message,
					channel  => $channel,
				}
			);

		# Prepare request.
		my $request = HTTP::Request->new(
			POST => $config->get( 'NotifyReleasesToSlack', 'slack_post_url' ),
		);
		$request->content( $request_payload );

		# Send request to Slack.
		my $user_agent = LWP::UserAgent->new();
		my $response = $user_agent->request( $request );

		# If the connection is down, or Slack is down, warn the user.
		if ( !$response->is_success() )
		{
			my $error = sprintf(
				"Failed to notify channel '%s' with message '%s'.\n>>> %s %s.",
				$channel,
				$message,
				$response->code(),
				$response->message(),
			);

			# Notify any logging systems.
			$log->error( $error );

			# Notify the user who is pushing tags.
			print STDERR "$error\n";
		}
	}

	return;
}


=head2 get_changelog_releases()

Retrieve a hashref of all the releases in the changelog file.

	my $releases = App::GitHooks::Plugin::NotifyReleasesToSlack::get_changelog_releases(
		$app
	);

Arguments:

=over 4

=item * $app I<(mandatory)>

An C<App::GitHooks> object.

=back

=cut

sub get_changelog_releases
{
	my ( $app ) = @_;
	my $repository = $app->get_repository();
	my $config = $app->get_config();

	# Make sure the changelog file exists.
	my $changelog_path = $config->get( 'NotifyReleasesToSlack', 'changelog_path' );
	$changelog_path = $repository->work_tree() . '/' . $changelog_path;
	die "The changelog '$changelog_path' specified in your .githooksrc config does not exist in the repository\n"
		if ! -e $changelog_path;
	$log->infof( "Using changelog '%s'.", $changelog_path );

	# Read the changelog.
	my $changes =
	try
	{
		return CPAN::Changes->load( $changelog_path );
	}
	catch
	{
		$log->error( "Unable to parse the change log" );
		die "Unable to parse the change log\n";
	};
	$log->info( 'Successfully parsed the change log file.' );

	# Organize the releases into a hash for easy lookup.
	my $releases =
	{
		map { $_->version() => $_ }
		$changes->releases()
	};

	return $releases;
}


=head2 get_pushed_tags()

Retrieve a list of the tags being pushed with C<git push>.

	my @tags = App::GitHooks::Plugin::NotifyReleasesToSlack::get_pushed_tags(
		$app,
		$stdin,
	);

Arguments:

=over 4

=item * $app I<(mandatory)>

An C<App::GitHooks> object.

=item * $stdin I<(mandatory)>

The content provided by git on stdin, corresponding to a list of references
being pushed.

=back

=cut

sub get_pushed_tags
{
	my ( $app, $stdin ) = @_;
	my $config = $app->get_config();

	# Tag pattern.
	my $git_tag_regex = $config->get_regex( 'NotifyReleasesToSlack', 'git_tag_regex' )
		// '(v\d+\.\d+\.\d+)';
	$log->infof( "Using git tag regex '%s'.", $git_tag_regex );

	# Analyze each reference being pushed.
	my $tags = {};
	foreach my $line ( @$stdin )
	{
		chomp( $line );
		$log->debugf( 'Parse STDIN line >%s<.', $line );

		# Extract the tag information.
		my ( $tag ) = ( $line =~ /^refs\/tags\/$git_tag_regex\b/x );
		next if !defined( $tag );
		$log->infof( "Found tag '%s'.", $tag );
		$tags->{ $tag } = 1;
	}

	return keys %$tags;
}


=head1 BUGS

Please report any bugs or feature requests through the web interface at
L<https://github.com/guillaumeaubert/App-GitHooks-Plugin-NotifyReleasesToSlack/issues/new>.
I will be notified, and then you'll automatically be notified of progress on
your bug as I make changes.


=head1 SUPPORT

You can find documentation for this module with the perldoc command.

	perldoc App::GitHooks::Plugin::NotifyReleasesToSlack


You can also look for information at:

=over

=item * GitHub's request tracker

L<https://github.com/guillaumeaubert/App-GitHooks-Plugin-NotifyReleasesToSlack/issues>

=item * AnnoCPAN: Annotated CPAN documentation

L<http://annocpan.org/dist/app-githooks-plugin-notifyreleasestoslack>

=item * CPAN Ratings

L<http://cpanratings.perl.org/d/app-githooks-plugin-notifyreleasestoslack>

=item * MetaCPAN

L<https://metacpan.org/release/App-GitHooks-Plugin-NotifyReleasesToSlack>

=back


=head1 AUTHOR

L<Guillaume Aubert|https://metacpan.org/author/AUBERTG>,
C<< <aubertg at cpan.org> >>.


=head1 COPYRIGHT & LICENSE

Copyright 2013-2016 Guillaume Aubert.

This code is free software; you can redistribute it and/or modify it under the
same terms as Perl 5 itself.

This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the LICENSE file for more details.

=cut

1;


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.