File Coverage

lib/Git/Hooks/CheckYoutrack.pm
Criterion Covered Total %
statement 27 165 16.3
branch 0 50 0.0
condition 0 22 0.0
subroutine 9 20 45.0
pod 0 5 0.0
total 36 262 13.7


line stmt bran cond sub pod time code
1             # ========================================================================== #
2             # lib/Git/Hooks/Youtrack.pm - Github Hooks for youtrack
3             # ========================================================================== #
4              
5             package Git::Hooks::CheckYoutrack;
6              
7 1     1   624 use strict;
  1         2  
  1         25  
8 1     1   4 use warnings;
  1         2  
  1         50  
9 1     1   6 use utf8;
  1         1  
  1         11  
10 1     1   30 use Log::Any '$log';
  1         2  
  1         13  
11 1     1   280 use Path::Tiny;
  1         2  
  1         41  
12 1     1   516 use Git::Hooks;
  1         4736  
  1         98  
13 1     1   574 use LWP::UserAgent;
  1         41756  
  1         40  
14 1     1   544 use URI::Builder;
  1         2254  
  1         28  
15 1     1   657 use JSON::XS;
  1         2642  
  1         1689  
16              
17             $Git::Hooks::CheckYoutrack::VERSION = '1.0.1';
18              
19             =head1 NAME
20              
21             Git::Hooks::CheckYoutrack - Git::Hooks plugin which requires youtrack ticket number on each commit message
22              
23             =head1 SYNOPSIS
24              
25             As a C plugin you don't use this Perl module directly. Instead, you
26             may configure it in a Git configuration file like this:
27              
28             [githooks]
29            
30             # Enable the plugin
31             plugin = CheckYoutrack
32              
33             [githooks "checkyoutrack"]
34              
35             # '/youtrack' will be appended to this host
36             youtrack-host = "https://example.myjetbrains.com"
37              
38             # Refer: https://www.jetbrains.com/help/youtrack/standalone/Manage-Permanent-Token.html
39             # to create a Bearer token
40             youtrack-token = ""
41              
42             # Regular expression to match for Youtrack ticket id
43             matchkey = '^((?:P|M)(?:AY|\d+)-\d+)'
44              
45             # Setting this flag will aborts the commit if valid Youtrack number not found
46             # Shows a warning message otherwise - default false
47             required = true
48              
49             # Print the fetched youtrack ticket details like Assignee, State etc..,
50             # default false
51             print-info = true
52              
53              
54             =head1 DESCRIPTION
55              
56             This plugin hooks the following git hooks to guarantee that every commit message
57             cites a valid Youtrack Id in the log message, so that you can be certain that
58             every commit message has a valid link to the Youtrack ticket. Refer L
59             for steps to install and use Git::Hooks
60              
61             This plugin also hooks prepare-commit-msg to pre-populate youtrack ticket sumary on the
62             commit message if the current working branch name is starting with the valid ticket number
63              
64             =head1 METHODS
65              
66             =cut
67              
68             my $PKG = __PACKAGE__;
69             (my $CFG = __PACKAGE__) =~ s/.*::/githooks./;
70              
71             # =========================================================================== #
72              
73             =head2 B, B
74            
75             These hooks are invoked during the commit, to check if the commit message
76             starts with a valid Youtrack ticket Id.
77              
78             =cut
79              
80             sub check_commit_msg {
81 0     0 0   my ($git, $message) = @_;
82              
83             # Skip for empty message
84 0 0 0       return 'no_check' if (!$message || $message =~ /^[\n\r]$/g);
85              
86 0           my $yt_id = _get_youtrack_id($git, $message);
87              
88 0 0         if (!$yt_id) {
89 0           return _show_error($git, "Missing youtrack ticket id in your message: $message");
90             }
91              
92 0           $log->debug("Found Youtrack ticket id from message as: $yt_id");
93              
94 0           my $yt_ticket = _get_ticket($git, $yt_id);
95              
96 0 0         if (!$yt_ticket) {
97 0           return _show_error($git, "Youtrack ticket not found with ID: $yt_id");
98             }
99              
100 0 0 0       if ($yt_ticket && $git->get_config_boolean($CFG => 'print-info')) {
101 0           print '-' x 80 . "\n";
102 0           print "Youtrack ticket: $yt_ticket->{ticket_id}\n";
103 0           print "Summary: $yt_ticket->{summary}\n";
104 0           print "Current status: $yt_ticket->{State}\n";
105 0           print "Assigned to: $yt_ticket->{Assignee}\n";
106 0           print "Ticket Link: $yt_ticket->{WebLink}\n";
107 0           print '-' x 80 . "\n";
108             }
109              
110 0           return 0;
111             }
112              
113             # =========================================================================== #
114              
115             sub check_message_file {
116 0     0 0   my ($git, $commit_msg_file) = @_;
117              
118 0           $log->debug(__PACKAGE__ . "::check_message_file($commit_msg_file)");
119              
120 0           _setup_config($git);
121              
122 0           my $msg = _get_message_from_file($git, $commit_msg_file);
123              
124             # Remove comment lines from the message file contents.
125 0           $msg =~ s/^#[^\n]*\n//mgs;
126              
127 0           return check_commit_msg($git, $msg);
128             }
129              
130             # =========================================================================== #
131              
132             sub _show_error {
133 0     0     my ($git, $msg) = @_;
134 0 0         if ($git->get_config_boolean($CFG => 'required')) {
135 0           $git->fault("ERROR: $msg");
136 0           return 1;
137             }
138             else {
139 0           print "WARNING: $msg\n";
140 0           return 0;
141             }
142             }
143              
144             # =========================================================================== #
145              
146             =head2 B
147              
148             This hook is for remote repository and should be installed and configured at the remote git server.
149             Checks for youtrack ticket on each commit message pushed to the remote repository and deny push
150             if its not found and its required = true in the config, shows a warning message on client side
151             if config required = false but accepts the push.
152              
153             =cut
154              
155             sub check_affected_refs {
156 0     0 0   my ($git) = @_;
157              
158 0           $log->debug(__PACKAGE__ . "::check_affected_refs");
159              
160 0           _setup_config($git);
161              
162 0           my $errors = 0;
163              
164 0           foreach my $ref ($git->get_affected_refs()) {
165 0 0         next unless $git->is_reference_enabled($ref);
166 0 0         check_ref($git, $ref)
167             or ++$errors;
168             }
169              
170 0 0         if($errors) {
171 0           return _show_error($git, "Some of your commit message missing a valid youtrack ticket");
172             }
173              
174 0           return 0;
175             }
176              
177             # =========================================================================== #
178              
179             sub check_ref {
180 0     0 0   my ($git, $ref) = @_;
181              
182 0           my $errors = 0;
183              
184 0           foreach my $commit ($git->get_affected_ref_commits($ref)) {
185 0 0         check_commit_msg($git, $commit->message) or ++$errors;
186             }
187              
188 0           return $errors == 0;
189             }
190              
191             # =========================================================================== #
192              
193             =head2 B
194            
195             This hook is invoked before a commit, to check if the current branch name start with
196             a valid youtrack ticket id and pre-populates the commit message with youtrack ticket: summary
197              
198             =cut
199              
200             sub add_youtrack_summary {
201 0     0 0   my ($git, $commit_msg_file) = @_;
202              
203 0           $log->debug(__PACKAGE__ . "::add_youtrack_summary($commit_msg_file)");
204              
205 0           _setup_config($git);
206              
207 0           my $msg = _get_message_from_file($git, $commit_msg_file);
208 0           my $msg_copy = $msg;
209              
210             # Remove comment lines and empty lines from the message file contents.
211 0           $msg =~ s/^#[^\n]*\n//mgs;
212 0           $msg =~ s/^\n*\n$//msg;
213              
214             # Don't do anything if message already exist (user used -m option)
215 0 0         if ($msg) {
216 0           $log->debug("Message exist: $msg");
217 0           return 0;
218             }
219              
220 0           my $current_branch = $git->get_current_branch();
221              
222             # Extract current branch name
223 0           $current_branch =~ s/.+\/(.+)/$1/;
224              
225 0           my $yt_id = _get_youtrack_id($git, $current_branch);
226              
227 0 0         if (!$yt_id) {
228 0           $log->warn("No youtrack id in your working branch");
229 0           return 0;
230             }
231              
232 0           my $task = _get_ticket($git, $yt_id);
233              
234 0 0         if (!$task) {
235 0           $log->warn("Your branch name does not match with youtrack ticket");
236 0           return 0;
237             }
238              
239 0           my $ticket_msg = "$yt_id: $task->{summary}\n";
240              
241 0           $log->debug("Pre-populating commit message as: $ticket_msg");
242              
243 0 0         open my $out, '>', path($commit_msg_file) or die "Can't write new file: $!";
244 0           print $out $ticket_msg;
245 0           print $out $msg_copy;
246 0           close $out;
247             }
248              
249             # =========================================================================== #
250              
251             sub _get_message_from_file {
252 0     0     my ($git, $file) = @_;
253              
254 0           my $msg = eval { path($file)->slurp };
  0            
255 0 0 0       defined $msg
256             or $git->fault("Cannot open file '$file' for reading:", {details => $@})
257             and return 0;
258              
259 0           return $msg;
260             }
261              
262             # =========================================================================== #
263              
264             # Setup default configs
265             sub _setup_config {
266 0     0     my ($git) = @_;
267              
268 0           my $config = $git->get_config();
269              
270 0   0       $config->{lc $CFG} //= {};
271              
272 0           my $default = $config->{lc $CFG};
273              
274             # Default matchkey for matching Youtrack ids (P3-1234 || PAY-1234) keys.
275 0   0       $default->{matchkey} //= ['^((?:P|M)(?:AY|\d+)-\d+)'];
276              
277 0   0       $default->{required} //= ['false'];
278              
279 0   0       $default->{'print-info'} //= ['false'];
280              
281 0           return;
282             }
283              
284             # =========================================================================== #
285              
286             # Tries to get a valid youtrack ticket and return a HashRef with ticket details if found success
287             sub _get_ticket {
288 0     0     my ($git, $ticket_id) = @_;
289              
290 0           my $yt_token = $git->get_config($CFG => 'youtrack-token');
291              
292 0 0         $yt_token = $ENV{YoutrackToken} if (!$yt_token);
293              
294 0 0         if (!$yt_token) {
295 0           my $error = "Please set Youtrack permanent token in ENV YoutrackToken\n";
296 0           $error .= "Refer: https://www.jetbrains.com/help/youtrack/standalone/Manage-Permanent-Token.html\n";
297 0           $error .= "to generate a token\n";
298 0           $git->fault($error);
299 0           return;
300             }
301              
302 0           my $yt_host = $git->get_config($CFG => 'youtrack-host');
303 0           my $yt = URI::Builder->new(uri => $yt_host);
304              
305 0   0       my $ua = $git->{yt_ua} //= LWP::UserAgent->new();
306 0           $ua->default_header('Authorization' => "Bearer $yt_token");
307 0           $ua->default_header('Accept' => 'application/json');
308 0           $ua->default_header('Content-Type' => 'application/json');
309              
310 0           my $ticket_fields = 'fields=numberInProject,project(shortName),summary,customFields(name,value(name))';
311              
312 0           $yt->path_segments("youtrack", "api", "issues", $ticket_id);
313              
314 0           my $url = $yt->as_string . "?$ticket_fields";
315              
316 0           my $ticket = $ua->get($url);
317              
318 0 0         if (!$ticket->is_success) {
319 0           $log->debug("Youtrack fetch failed");
320 0           return;
321             }
322              
323 0           my $json = decode_json($ticket->decoded_content);
324 0           my $ticket_details = _process_ticket($json);
325              
326 0 0         if (!$ticket_details->{ticket_id}) {
327 0           $log->debug("No valid youtrack ticket found");
328 0           return;
329             }
330              
331 0 0         $ticket_details->{Assignee} = 'Unassigned' if (!$ticket_details->{Assignee});
332              
333 0           $yt->path_segments('youtrack', 'issue', $ticket_id);
334 0           $ticket_details->{WebLink} = $yt->as_string;
335              
336 0           return $ticket_details;
337             }
338              
339             # =========================================================================== #
340              
341             # Helper method to process the response from Youtrack API
342             sub _process_ticket {
343 0     0     my $json = shift;
344              
345 0 0         return if (!$json);
346 0           my $ticket;
347              
348 0           $ticket->{summary} = $json->{summary};
349 0           $ticket->{type} = $json->{'$' . 'type'};
350 0           $ticket->{ticket_id} = $json->{numberInProject};
351              
352 0 0 0       if ($json->{project} && $json->{project}->{shortName}) {
353 0           $ticket->{ticket_id} = $json->{project}->{shortName} . '-' . $ticket->{ticket_id};
354             }
355              
356 0 0         if ($json->{customFields}) {
357 0           foreach my $field (@{$json->{customFields}}) {
  0            
358              
359 0 0         if (ref $field->{value} eq 'HASH') {
    0          
360 0           $ticket->{$field->{name}} = $field->{value}->{name};
361             }
362             elsif (ref $field->{value} eq 'ARRAY') {
363 0           foreach my $val (@{$field->{value}}) {
  0            
364 0           $ticket->{$field->{name}} = join(',', $val->{name});
365             }
366             }
367             else {
368 0           $ticket->{$field->{name}} = $field->{value};
369             }
370             }
371             }
372              
373 0           return $ticket;
374             }
375              
376             # =========================================================================== #
377              
378             # Check and return a youtrack Id in the given string based on the matchkey regex
379             sub _get_youtrack_id {
380 0     0     my ($git, $message) = @_;
381              
382 0           my $matchkey = $git->get_config($CFG => 'matchkey');
383              
384 0 0         if ($message =~ /$matchkey/i) {
385 0           return uc($1);
386             }
387              
388 0           return;
389             }
390              
391             # =========================================================================== #
392              
393             =head1 USAGE INSTRUCTION
394              
395             Create a generic script that will be invoked by Git for every hook. Go to hooks directory of your repository,
396             for local repository it is .git/hooks/ and for remote server it is ./hooks/ and create a simple executable perl script
397              
398             $ cd /path/to/repo/.git/hooks
399            
400             $ cat >git-hooks.pl <<'EOT'
401             #!/usr/bin/env perl
402             use Git::Hooks;
403             run_hook($0, @ARGV);
404             EOT
405            
406             $ chmod +x git-hooks.pl
407              
408             Now you should create symbolic links pointing to this perl script for each hook you are interested in
409              
410             For local repository
411              
412             $ cd /path/to/repo/.git/hooks
413              
414             $ ln -s git-hooks.pl commit-msg
415             $ ln -s git-hooks.pl applypatch-msg
416             $ ln -s git-hooks.pl prepare-commit-msg
417              
418             For remote repository
419              
420             $ cd /path/to/repo/hooks
421              
422             $ ln -s git-hooks.pl update
423              
424             =cut
425              
426             # Install hooks via Git::Hooks
427             APPLYPATCH_MSG \&check_message_file;
428             COMMIT_MSG \&check_message_file;
429             PREPARE_COMMIT_MSG \&add_youtrack_summary;
430             UPDATE \&check_affected_refs;
431              
432             # =========================================================================== #
433              
434             1;
435              
436             __END__