Group
Extension

Git-Hooks-CheckYoutrack/lib/Git/Hooks/CheckYoutrack.pm

# ========================================================================== #
# lib/Git/Hooks/Youtrack.pm - Github Hooks for youtrack
# ========================================================================== #

package Git::Hooks::CheckYoutrack;

use strict;
use warnings;
use utf8;
use Log::Any '$log';
use Path::Tiny;
use Git::Hooks;
use LWP::UserAgent;
use URI::Builder;
use JSON::XS;

$Git::Hooks::CheckYoutrack::VERSION = '1.0.2';

=head1 NAME

Git::Hooks::CheckYoutrack - Git::Hooks plugin which requires youtrack ticket number on each commit message

=head1 SYNOPSIS

As a C<Git::Hooks> plugin you don't use this Perl module directly. Instead, you
may configure it in a Git configuration file like this:

 [githooks]
 
    # Enable the plugin
    plugin = CheckYoutrack

 [githooks "checkyoutrack"]

    # '/youtrack' will be appended to this host
    youtrack-host = "https://example.myjetbrains.com"

    # Refer: https://www.jetbrains.com/help/youtrack/standalone/Manage-Permanent-Token.html
    # to create a Bearer token
    # You can also set YoutrackToken ENV instead of this config
    youtrack-token = "<your-token>"

    # Regular expression to match for Youtrack ticket id
    matchkey = "^((?:P)(?:AY|\\d+)-\\d+)"

    # Setting this flag will aborts the commit if valid Youtrack number not found
    # Shows a warning message otherwise - default false
    required = true 

    # Print the fetched youtrack ticket details like Assignee, State etc..,
    # default false
    print-info = true


=head1 DESCRIPTION

This plugin hooks the following git hooks to guarantee that every commit message 
cites a valid Youtrack Id in the log message, so that you can be certain that 
every commit message has a valid link to the Youtrack ticket. Refer L<Git::Hooks Usage|https://metacpan.org/pod/Git::Hooks#USAGE> 
for steps to install and use Git::Hooks

This plugin also hooks prepare-commit-msg to pre-populate youtrack ticket sumary on the 
commit message if the current working branch name is starting with the valid ticket number

=head1 METHODS

=cut

my $PKG = __PACKAGE__;
(my $CFG = __PACKAGE__) =~ s/.*::/githooks./;

# =========================================================================== #

=head2 B<commit-msg>, B<applypatch-msg>
 
These hooks are invoked during the commit, to check if the commit message
starts with a valid Youtrack ticket Id.

=cut

sub check_commit_msg {
    my ($git, $message, $commit_id) = @_;

    # Skip for empty message
    return 'no_check' if (!$message || $message =~ /^[\n\r]$/g);

    $log->info("Checking commit message: $message");

    my $yt_id = _get_youtrack_id($git, $message);

    if (!$yt_id) {
        return _show_error($git, "Missing youtrack ticket id in your message: $message");
    }

    $log->info("Extracted Youtrack ticket id from message as: $yt_id");

    my $yt_ticket = _get_ticket($git, $yt_id);

    if (!$yt_ticket) {
        return _show_error($git, "Youtrack ticket not found with ID: $yt_id");
    }

    if ($yt_ticket && $git->get_config_boolean($CFG => 'print-info')) {
        print '-' x 80 . "\n";
        print "For git commit:  $commit_id\n" if($commit_id);
        print "Youtrack ticket: $yt_ticket->{ticket_id}\n";
        print "Summary:         $yt_ticket->{summary}\n";
        print "Current status:  $yt_ticket->{State}\n";
        print "Assigned to:     $yt_ticket->{Assignee}\n";
        print "Ticket Link:     $yt_ticket->{WebLink}\n";
        print '-' x 80 . "\n";
    }

    return 0;
}

# =========================================================================== #

sub check_message_file {
    my ($git, $commit_msg_file) = @_;

    $log->debug(__PACKAGE__ . "::check_message_file($commit_msg_file)");

    _setup_config($git);

    my $msg = _get_message_from_file($git, $commit_msg_file);

    # Remove comment lines from the message file contents.
    $msg =~ s/^#[^\n]*\n//mgs;

    return check_commit_msg($git, $msg);
}

# =========================================================================== #

sub _show_error {
    my ($git, $msg) = @_;
    $log->error($msg);
    if ($git->get_config_boolean($CFG => 'required')) {
        $git->fault("ERROR: $msg");
        return 1;
    }
    else {
        print "WARNING: $msg\n";
        return 0;
    }
}

# =========================================================================== #

=head2 B<update>

This hook is for remote repository and should be installed and configured at the remote git server.
Checks for youtrack ticket on each commit message pushed to the remote repository and deny push
if its not found and its required = true in the config, shows a warning message on client side 
if config required = false but accepts the push.

=cut

sub check_affected_refs {
    my ($git) = @_;

    $log->debug(__PACKAGE__ . "::check_affected_refs");

    _setup_config($git);

    my $errors = 0;

    foreach my $ref ($git->get_affected_refs()) {
        next unless $git->is_reference_enabled($ref);
        if(check_ref($git, $ref)) {
            ++$errors;
        }
    }

    if($errors) {
        return _show_error($git, "Some of your commit message missing a valid youtrack ticket");
    }

    return 0;
}

# =========================================================================== #

sub check_ref {
    my ($git, $ref) = @_;

    my $errors = 0;

    foreach my $commit ($git->get_affected_ref_commits($ref)) {
        local $log->context->{commit_id} = $commit->commit;
        $log->info("Commit from : ", $commit->author_name, " Git Ref: ", $ref);
        if(check_commit_msg($git, $commit->message, $commit->commit)) {
            ++$errors;
        }
    }

    return $errors;
}

# =========================================================================== #

=head2 B<prepare-commit-msg>
 
This hook is invoked before a commit, to check if the current branch name start with 
a valid youtrack ticket id and pre-populates the commit message with youtrack ticket: summary

=cut

sub add_youtrack_summary {
    my ($git, $commit_msg_file) = @_;

    $log->debug(__PACKAGE__ . "::add_youtrack_summary($commit_msg_file)");

    _setup_config($git);

    my $msg      = _get_message_from_file($git, $commit_msg_file);
    my $msg_copy = $msg;

    # Remove comment lines and empty lines from the message file contents.
    $msg =~ s/^#[^\n]*\n//mgs;
    $msg =~ s/^\n*\n$//msg;

    # Don't do anything if message already exist (user used -m option)
    if ($msg) {
        $log->info("Message exist: $msg");
        return 0;
    }

    my $current_branch = $git->get_current_branch();

    # Extract current branch name
    $current_branch =~ s/.+\/(.+)/$1/;

    my $yt_id = _get_youtrack_id($git, $current_branch);

    if (!$yt_id) {
        $log->warn("No youtrack id in your working branch");
        return 0;
    }

    my $task = _get_ticket($git, $yt_id);

    if (!$task) {
        $log->warn("Your branch name does not match with youtrack ticket");
        return 0;
    }

    my $ticket_msg = "$yt_id: $task->{summary}\n";

    $log->info("Pre-populating commit message as: $ticket_msg");

    open my $out, '>', path($commit_msg_file) or die "Can't write new file: $!";
    print $out $ticket_msg;
    print $out $msg_copy;
    close $out;
}

# =========================================================================== #

sub _get_message_from_file {
    my ($git, $file) = @_;

    my $msg = eval { path($file)->slurp };
    defined $msg
      or $git->fault("Cannot open file '$file' for reading:", {details => $@})
      and return 0;

    return $msg;
}

# =========================================================================== #

# Setup default configs
sub _setup_config {
    my ($git) = @_;

    my $config = $git->get_config();

    $config->{lc $CFG} //= {};

    my $default = $config->{lc $CFG};

    # Default matchkey for matching Youtrack ids (P3-1234 || PAY-1234) keys.
    $default->{matchkey} //= ['^(P(?:AY|\d+)-\d+)'];

    $default->{required} //= ['false'];

    $default->{'print-info'} //= ['false'];

    return;
}

# =========================================================================== #

# Tries to get a valid youtrack ticket and return a HashRef with ticket details if found success
sub _get_ticket {
    my ($git, $ticket_id) = @_;

    my $yt_token = $git->get_config($CFG => 'youtrack-token');

    $yt_token = $ENV{YoutrackToken} if (!$yt_token);

    if (!$yt_token) {
        my $error = "Please set Youtrack permanent token in ENV YoutrackToken\n";
        $error .= "Refer: https://www.jetbrains.com/help/youtrack/standalone/Manage-Permanent-Token.html\n";
        $error .= "to generate a token\n";
        $git->fault($error);
        return;
    }

    my $yt_host = $git->get_config($CFG => 'youtrack-host');
    my $yt      = URI::Builder->new(uri => $yt_host);

    my $ua = $git->{yt_ua} //= LWP::UserAgent->new();
    $ua->default_header('Authorization' => "Bearer $yt_token");
    $ua->default_header('Accept'        => 'application/json');
    $ua->default_header('Content-Type'  => 'application/json');

    my $ticket_fields = 'fields=numberInProject,project(shortName),summary,customFields(name,value(name))';

    $yt->path_segments("youtrack", "api", "issues", $ticket_id);

    my $url = $yt->as_string . "?$ticket_fields";

    my $ticket = $ua->get($url);

    if (!$ticket->is_success) {
        $log->error("Youtrack fetch failed with status: ", $ticket->status_line);
        return;
    }

    my $json           = decode_json($ticket->decoded_content);
    my $ticket_details = _process_ticket($json);

    if (!$ticket_details->{ticket_id}) {
        $log->error("No valid youtrack ticket found");
        return;
    }

    $ticket_details->{Assignee} = 'Unassigned' if (!$ticket_details->{Assignee});

    $yt->path_segments('youtrack', 'issue', $ticket_id);
    $ticket_details->{WebLink} = $yt->as_string;

    return $ticket_details;
}

# =========================================================================== #

# Helper method to process the response from Youtrack API
sub _process_ticket {
    my $json = shift;

    return if (!$json);
    my $ticket;

    $ticket->{summary}   = $json->{summary};
    $ticket->{type}      = $json->{'$' . 'type'};
    $ticket->{ticket_id} = $json->{numberInProject};

    if ($json->{project} && $json->{project}->{shortName}) {
        $ticket->{ticket_id} = $json->{project}->{shortName} . '-' . $ticket->{ticket_id};
    }

    if ($json->{customFields}) {
        foreach my $field (@{$json->{customFields}}) {

            if (ref $field->{value} eq 'HASH') {
                $ticket->{$field->{name}} = $field->{value}->{name};
            }
            elsif (ref $field->{value} eq 'ARRAY') {
                foreach my $val (@{$field->{value}}) {
                    $ticket->{$field->{name}} = join(',', $val->{name});
                }
            }
            else {
                $ticket->{$field->{name}} = $field->{value};
            }
        }
    }

    return $ticket;
}

# =========================================================================== #

# Check and return a youtrack Id in the given string based on the matchkey regex
sub _get_youtrack_id {
    my ($git, $message) = @_;

    my $matchkey = $git->get_config($CFG => 'matchkey');

    chomp $message;

    if ($message =~ /$matchkey/i) {
        return uc($1);
    }

    $log->info("\"$message\" does not match /$matchkey/");

    return;
}

# =========================================================================== #

=head1 USAGE INSTRUCTION

Create a generic script that will be invoked by Git for every hook. Go to hooks directory of your repository,
for local repository it is .git/hooks/ and for remote server it is ./hooks/ and create a simple executable perl script

    $ cd /path/to/repo/.git/hooks
 
    $ cat >git-hooks.pl <<'EOT'
    #!/usr/bin/env perl
    use Git::Hooks;
    run_hook($0, @ARGV);
    EOT
 
    $ chmod +x git-hooks.pl

Now you should create symbolic links pointing to this perl script for each hook you are interested in

For local repository

    $ cd /path/to/repo/.git/hooks

    $ ln -s git-hooks.pl commit-msg
    $ ln -s git-hooks.pl applypatch-msg
    $ ln -s git-hooks.pl prepare-commit-msg

For remote repository

    $ cd /path/to/repo/hooks

    $ ln -s git-hooks.pl update

=cut

# Install hooks via Git::Hooks
APPLYPATCH_MSG \&check_message_file;
COMMIT_MSG \&check_message_file;
PREPARE_COMMIT_MSG \&add_youtrack_summary;
UPDATE \&check_affected_refs;

# =========================================================================== #

1;

__END__

=head1 SEE ALSO

Git::Hooks

=head1 AUTHORS

Dinesh Dharmalingam, <dd.dinesh.rajakumar@gmail.com>

=head1 LICENSE

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

=cut



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