File Coverage

lib/Git/Hooks/CheckYoutrack.pm
Criterion Covered Total %
statement 27 174 15.5
branch 0 52 0.0
condition 0 22 0.0
subroutine 9 20 45.0
pod 0 5 0.0
total 36 273 13.1


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